From 115b3796e824f09175a6ed2e140220190cc4670a Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 10 Aug 2026 21:36:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B0=D1=80=D0=B5=D0=B7=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=20=D0=BF=D0=BE=D0=B4=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=B2=D0=BE=D0=B5=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5:=20PWA,=20=D0=B2=D1=85=D0=BE?= =?UTF-8?q?=D0=B4,=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Роадмап: web-access переименована под приложение, которое ставится на телефон; заведена цель ready-notification — уведомление о готовности без открытого приложения. Беклог: десять задач. Многопользовательская цепочка (oidc-login, record-ownership, telegram-account-link), веб (json-api-for-spa, spa-skeleton, upload-and-status-screen, records-list-screen, installable-pwa), уведомления через apprise и ntfy, разведка выбора фреймворка. Очередь: долги, хранилище, вход, приложение. Решение сменилось с htmx на SPA, поэтому conventions/web-ui.md снята целиком и оставлена честной строкой до итога разведки. Открытые вопросы архитектуры, границы паспорта и триггеры метки ревью приведены в соответствие. --- docs/architecture.md | 13 +- docs/conventions/README.md | 13 +- docs/conventions/web-ui.md | 171 +++++------------------- docs/passport.md | 8 +- docs/review.md | 8 +- tasks/BACKLOG.md | 12 +- tasks/ROADMAP.md | 3 +- tasks/items/installable-pwa.md | 45 +++++++ tasks/items/json-api-for-spa.md | 44 ++++++ tasks/items/ntfy-delivery.md | 46 +++++++ tasks/items/oidc-login.md | 50 +++++++ tasks/items/ready-notification.md | 25 ++++ tasks/items/record-ownership.md | 38 ++++++ tasks/items/records-list-screen.md | 37 +++++ tasks/items/spa-framework-choice.md | 34 +++++ tasks/items/spa-skeleton.md | 43 ++++++ tasks/items/telegram-account-link.md | 43 ++++++ tasks/items/upload-and-status-screen.md | 38 ++++++ tasks/items/web-access.md | 25 ++-- 19 files changed, 536 insertions(+), 160 deletions(-) create mode 100644 tasks/items/installable-pwa.md create mode 100644 tasks/items/json-api-for-spa.md create mode 100644 tasks/items/ntfy-delivery.md create mode 100644 tasks/items/oidc-login.md create mode 100644 tasks/items/ready-notification.md create mode 100644 tasks/items/record-ownership.md create mode 100644 tasks/items/records-list-screen.md create mode 100644 tasks/items/spa-framework-choice.md create mode 100644 tasks/items/spa-skeleton.md create mode 100644 tasks/items/telegram-account-link.md create mode 100644 tasks/items/upload-and-status-screen.md diff --git a/docs/architecture.md b/docs/architecture.md index 5bf9a73..2d84bc0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -120,8 +120,17 @@ другим. Данные не переносим — начинаем с чистого листа. - **Учётные записи.** Вход через OIDC, провайдер — Authelia. Не решено, где живёт сессия и как связываются пользователь Telegram и пользователь веба. -- **Веб-интерфейс.** Формы нет вовсе, есть только API. Конвенция веб-UI на htmx - описана в [conventions/web-ui.md](conventions/web-ui.md) заранее. +- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA, + устанавливаемое на телефон; фреймворк выбирает разведка + `spa-framework-choice`, и до её итога + [conventions/web-ui.md](conventions/web-ui.md) стоит почти пустой. Шаг сборки + фронтенда меняет требования к машине разработчика и к образу — решение уровня + ADR. +- **Уведомления.** Пользователь веба узнаёт о готовности только опросом. + Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web + Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и + текст расшифровки начинает уходить на сторону — сдвиг периметра + [security.md](security.md). - **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём из Telegram — точно, ограничения `deferred-general` по длине — нет. - **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а diff --git a/docs/conventions/README.md b/docs/conventions/README.md index 58e7462..2804e98 100644 --- a/docs/conventions/README.md +++ b/docs/conventions/README.md @@ -15,11 +15,14 @@ severity — в [CLAUDE.md](../../CLAUDE.md). ## Откуда взяты и что с расхождениями -Все пять записей перенесены из проекта jellybit — тот же Go, тот же автор, те же +Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же задачи. Код transcriber написан раньше и **части правил не следует**: ключи — UUID вместо ULID, время берётся `time.Now()` по месту, лог пишется на каждом шаге и дублируется воркером, доменные ошибки проверяются приведением типа. +Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на +htmx, а здесь решено делать SPA — и перенесённый текст снят целиком. + Каждое такое место названо в своей записи строкой «*Расхождение:*». Читается оно как **долг, а не как нарушение**: правила действуют на новый код, переписывание существующего — отдельная работа. Проходу ревью строка «Расхождение» говорит, что @@ -36,10 +39,10 @@ UUID вместо ULID, время берётся `time.Now()` по месту, `0600`, самодокументируемый `config.dist.toml`, проверка на старте. - [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT ULID, разбор на входной границе, естественные ключи у деталей. -- [web-ui.md](web-ui.md) — веб-UI на htmx: партиал равен странице равен - фрагменту, ветвление по `HX-Request`, деградация без JS, ошибка на htmx-пути - как 200 плюс фрагмент, самозавершающийся опрос, вендоринг статики. **Записана - наперёд: веб-UI ещё нет.** +- [web-ui.md](web-ui.md) — веб-UI: что решено про приложение (SPA, установка на + телефон, статика в бинарнике) и что ждёт выбора фреймворка. **Почти пуста:** + редакция на htmx снята 2026-08-10 вместе со сменой решения, а новую писать не + под что до разведки `spa-framework-choice`. ## Механизировано diff --git a/docs/conventions/web-ui.md b/docs/conventions/web-ui.md index af959bc..5505fee 100644 --- a/docs/conventions/web-ui.md +++ b/docs/conventions/web-ui.md @@ -1,146 +1,41 @@ -# Веб-UI (htmx) +# Веб-UI -Конвенция: *как* мы пишем код веб-UI — частичная замена фрагментов, опрос живых -обновлений, обработчики действий, деградация без JS, ошибки. Это правила -оформления кода (How), а не спецификация поведения — что именно UI показывает и -какие действия обязан поддерживать, живёт в спеке OpenSpec. +Конвенция: *как* мы пишем код приложения. Это правила оформления кода (How), а не +спецификация поведения — что именно приложение показывает и какие действия +обязано поддерживать, живёт в спеке OpenSpec. -**Взято из проекта jellybit и записано наперёд: веб-UI в transcriber нет вовсе.** -Есть только HTTP API на gin. Ни одного расхождения назвать нельзя — нечему -расходиться; правила действуют с первой страницы, которую заведём. +**Фреймворк не выбран, и до выбора эта конвенция пуста.** Здесь стоит только то, +что решено и от фреймворка не зависит; всё остальное появится по итогу разведки +`spa-framework-choice`, которая же заведёт запись в `research/` и ADR. + +Прежняя редакция описывала htmx с прямым запретом на шаг сборки и реактивные +фреймворки. Решение сменилось на SPA 2026-08-10, и та редакция снята целиком: +частичная подмена фрагментов, ветвление по `HX-Request` и деградация без JS к +SPA не относятся ни одним пунктом. Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок -наружу — [errors.md](errors.md). Здесь — только особенности htmx-транспорта, без -повторения. +наружу — [errors.md](errors.md). -## Стек и границы +## Что решено -htmx-first: `gin` плюс `html/template` (рендер на сервере) плюс htmx. Ничего -сверх этого: **без шага сборки, без Node и сборщика, без реактивных -фреймворков**. htmx вендорится и раздаётся с нашего же хоста (`go:embed`, -`/static/vendor/`), без CDN. +- **Приложение — SPA**, а не страницы, отрисованные сервером. Сервер отдаёт + контракт данных, разметку собирает клиент. +- **Приложение ставится на телефон** и запускается с ярлыка: манифест, иконки, + service worker. +- **Статика вшивается в бинарник** через `go:embed` и раздаётся им же. Внешнего + веб-сервера под статику не заводим, бинарник остаётся самодостаточным. +- **Шрифты и скрипты — со своего хоста**, без внешних. Внешних ресурсов времени + выполнения нет. +- **Офлайн-чтения расшифровок и очереди отправки без сети не делаем** — граница + цели [web-access](../../tasks/items/web-access.md). Без сети приложение + показывает состояние, а не пустой экран. +- **Web Push не делаем**: уведомления идут через apprise и ntfy, цель + [ready-notification](../../tasks/items/ready-notification.md). +- **Записи звука в приложении не делаем** — файл выбирают системным диалогом. -- Свой JS сведён к минимуму: только то, чего серверу знать не нужно. - **Клиентского пересчёта доменного состояния нет** — состояние считает сервер, - клиент лишь подменяет присланную разметку. -- Alpine.js и SPA сознательно **не вводим**. Понадобится реактивный клиентский - виджет — вводим отдельным изменением и записываем решение, не раньше. +## Что не решено -## Единый источник разметки: партиал равен странице равен фрагменту - -Переиспользуемый кусок — это `{{define "name"}}` в каталоге партиалов. Тот же -`{{define}}` рендерится **и** внутри страницы (`{{template "name" .}}`), **и** -как ответ-фрагмент того же обработчика. Отдельной разметки под фрагмент не -заводим — иначе она разъедется со страницей. - -**Инвариант: корень `{{define}}` — это элемент с целевым `id`**, например -`#job-{id}`. `hx-swap="outerHTML"` заменяет весь корневой узел; если ответный -фрагмент не несёт тот же корневой `id`, следующее действие или опрос не найдёт -цель. Разметку и `id` держим в одном партиале. - -Сборку данных для шаблона выносим в отдельную функцию и зовём её и на полной -странице, и во фрагменте — чтобы htmx-ветка не копировала сборку. - -## Обработчик действия: ветвление htmx и редирект - -htmx-запрос определяем по заголовку `HX-Request: true`. - -Обработчик действия зовёт доменную операцию **одинаково** в обеих ветках, а -дальше ветвится: без htmx — привычный редирект после POST (303); с htmx — -перечитать актуальное состояние, собрать данные тем же сборщиком и отдать -фрагмент. - -Рендер именованного шаблона идёт **в буфер** и только затем пишется в ответ: при -ошибке шаблона клиент не получит полстраницы. - -## Деградация без JS обязательна - -Формы действий остаются обычными `
`, а -`hx-post`, `hx-target` и `hx-swap` лишь **накладываются сверху** на ту же форму. -Без JS всё работает через POST и редирект. Атрибут `action` — рабочий запасной -путь, а не украшение. - -Фильтр, поиск и разбиение списка на страницы — **серверные**, параметрами -запроса, тоже без JS. Клиентской фильтрации нет намеренно. - -## Ошибки на htmx-пути: HTTP 200 и фрагмент - -htmx по умолчанию **не подменяет DOM на ответы 4xx и 5xx**. Поэтому при ошибке -действия обработчик отвечает **200 с фрагментом**, несущим сообщение. Доменную -ошибку на htmx-пути **не** транслируем в HTTP-статус — в отличие от API и от -пути без JS. - -- Сообщение — нейтральный текст публичного канала (см. [errors.md](errors.md)); - сырой `err.Error()` наружу не идёт. -- Ошибку кладём в **отдельное поле** под ошибку действия, не перегружая доменные - поля: непустое доменное поле перекрыло бы сообщение. -- **При ошибке активное состояние не меняем** — перечитанные данные показывают - прежний выбор плюс сообщение. - -## Живой опрос - -Приём живого обновления: эндпоинт фрагмента плюс в разметке `hx-get`, -`hx-trigger="every Ns"` и `hx-swap="outerHTML"`. Прямой предмет опроса в -transcriber — карточка задачи, пока та не дошла до `done` или `failed`. - -- **Один опросчик на обновляемый корень.** Опрашивает себя корень поверхности, а - вложенные живые области своего `hx-get` **не несут**: подмена корня уносит их - вместе с таймером, и два опроса подменяли бы разметку друг друга. -- **Опросчик самозавершается.** Опрос идёт, пока предмет может измениться без - участия браузера; перестал — фрагмент возвращается **без `hx-*`**, и htmx - больше не опрашивает. Для задачи это значит: `created`, `converted` и - `transcribe` наблюдаемы, `done` и `failed` — нет. -- **Отказ тика тоже самозавершается.** Не сумев прочитать задачу, тик отвечает - `200` и фрагментом с объяснением **без `hx-*`**: htmx не подменяет DOM на - `4xx` и `5xx`, поэтому статус ошибки оставил бы поверхность навсегда прежней, а - опрос — бесконечным. Фрагмент отказа обязан нести корневой `id` того узла, - который он собой заменяет. -- **Уровень лога у тика — `WARN`.** У повторяющегося опроса есть штатный повтор; - `ERROR` оставляем разовому действию человека (см. [logging.md](logging.md)). -- **Подмена всего фрагмента через `outerHTML`** удаляет старый узел вместе с его - опросчиком, и htmx заново размечает новый — двойного опроса нет **при условии - совпадения корневого `id`**. Эфемерное состояние разметки подмену не - переживает: то, что должно пережить тик (раскрытый `
`), помечается - `hx-preserve`. -- **Частота — по цене тика, и она называется числом** в таблице настроек - [../database.md](../database.md) в тот же момент, когда заводится первый - опрашиваемый экран; сегодня такой настройки нет. Опрашивать чаще, чем меняется - источник, бессмысленно: задачу двигает воркер с шагом в секунду. -- **Тик ходит в БД, и это цена решения.** Читать состояние задачи дешевле, чем - держать снимок в памяти, но каждый открытый браузер добавляет запросов. - -## Подмена сохраняет контекст; выход — навигация - -`hx-swap="outerHTML"` не сбрасывает прокрутку и не трогает серверные фильтр, -поиск и страницу (они в параметрах запроса). Действие **не должно уводить** -пользователя со страницы, если предмет остаётся на ней. - -Действие, после которого предмет **покидает** страницу (удаление записи), -остаётся **обычной POST-формой без `hx-*`**, то есть полной навигацией. Признак -«это выход» — форма без htmx-атрибутов; так не нужен `HX-Redirect`, а «уйти с -экрана» выражено самой навигацией. - -**Асинхронные действия.** Доменное действие асинхронно почти всегда: загрузка -записи только заводит задачу, работу доделывают воркеры. Подмена отдаёт -**промежуточное** состояние, а не мнимый результат; готовый итог догоняем -самозавершающимся опросом. Мгновенный итог в UI не обещаем. - -## Различение поверхности одного действия - -Один и тот же роут действия, вызванный с разных страниц, отдаёт разные фрагменты. -Различаем **явным скрытым полем формы** `surface=list|detail`, а не догадкой по -`HX-Target` или `Referer`: поле самодокументируемо и не зависит от разрешения -цели. - -## Статика, вендоринг, кэш - -- Ресурсы встроены через `go:embed`, отдаются под `/static/` с длинным - неизменяемым кэшем (`Cache-Control: public, max-age=31536000, immutable`). -- Меняемые ресурсы (css, js) версионируются параметром `?v=<версия>` — коротким - sha256 их содержимого, URL строит помощник шаблона. Свежая выкладка не отдаёт - устаревший файл. -- Вендор (htmx, шрифты) адресуется по **неизменному имени файла**, и параметр - версии ему не нужен. В git его **не коммитим**; задача сборки идемпотентно - добывает его по манифесту со сверкой sha256. -- Шрифты и скрипты — **со своего хоста**, без внешних. Бинарник самодостаточен, - внешних ресурсов времени выполнения нет. +Фреймворк, форма сборки, способ хранения состояния на клиенте, правила +разбиения на компоненты, обращение к API и показ ошибок. Всё это — предмет +`spa-framework-choice`; писать их наперёд, не зная фреймворка, значит написать +правила, которые придётся выбросить второй раз. diff --git a/docs/passport.md b/docs/passport.md index d1d5c30..11556ff 100644 --- a/docs/passport.md +++ b/docs/passport.md @@ -14,7 +14,7 @@ | Кто | Что ему нужно от нас | | --- | --- | | Владелец сервиса | Отправить голосовое сообщение из Telegram и получить текст ответом. Работает сегодня | -| Приглашённый пользователь | Войти в веб через свою учётную запись, загрузить запись, забрать текст. Каждый видит только свои записи | +| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст. Приложение ставится на телефон; каждый видит только свои записи | | Внешняя программа | Отдать файл по HTTP и опросить готовность. Работает сегодня, без разграничения доступа | Цель достигнута, когда: @@ -24,7 +24,8 @@ - запись длиной в несколько часов доходит до текста, а не прерывается ошибкой при достижении предела; - сервисом пользуются несколько человек, и записи одного не видны другому; -- текст доступен там же, где загружали, — в вебе и в Telegram. +- текст доступен там же, где загружали, — в приложении и в Telegram, и человек + узнаёт о его готовности, не держа приложение открытым. ## Что целью не является @@ -45,6 +46,9 @@ провайдер, свою регистрацию и свои пароли не делаем. - **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не обрабатываем. +- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый + файл. Своей записи и работы без сети не делаем — граница цели + [web-access](../tasks/items/web-access.md). ## Типовые сценарии diff --git a/docs/review.md b/docs/review.md index 6ad7004..629ec9a 100644 --- a/docs/review.md +++ b/docs/review.md @@ -113,13 +113,17 @@ - изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и колонку разом; - замена хранилища или переход на PocketBase — любой её кусок; -- введение веб-UI: транспорт, шаблоны и статика одновременно; +- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом; - изменение, трогающее оба входа сразу — Telegram и HTTP. **Незнакомое здесь** (поднимает до `large`, ось формы решения): - вход через OIDC и разграничение доступа: как связаны пользователь Telegram и - пользователь веба, до начала работы назвать нельзя; + пользователь приложения, до начала работы назвать нельзя; +- всё, что делается на выбранном фреймворке впервые: форма решения нащупывается + по ходу, пока конвенция веб-UI пуста; +- установка на телефон: service worker перехватывает запросы, и что он кэширует, + до работы назвать нельзя; - работа с записями в несколько часов: потолки внешних сервисов не замерены, форма решения зависит от замера; - приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой; diff --git a/tasks/BACKLOG.md b/tasks/BACKLOG.md index 2c1c806..5a0f274 100644 --- a/tasks/BACKLOG.md +++ b/tasks/BACKLOG.md @@ -17,11 +17,21 @@ ## Ядро +- [🐞 Починить тесты http-обработчика, ни разу не бывшие зелёными](items/http-handler-tests-never-green.md) — go test ./... падает на master: тесты требуют файла, которого нет в репозитории, и ждут 201 от ffprobe, которому скормили строку. +- [✨ Пускать в приложение только после входа через OIDC](items/oidc-login.md) — HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору. +- [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать. +- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны. +- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем. +- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит. +- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем. +- [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана. - [🧹 Сравнивать доменные ошибки через errors.As](items/errors-as-instead-of-typecast.md) — NoopJobError и JobNotFoundError проверяются приведением типа: первая же обёртка %w между слоями сломает проверку молча. - [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается. -- [🐞 Починить тесты http-обработчика, ни разу не бывшие зелёными](items/http-handler-tests-never-green.md) — go test ./... падает на master: тесты требуют файла, которого нет в репозитории, и ждут 201 от ffprobe, которому скормили строку. ## Инфра - [🧹 Перевести хранилище на встроенный PocketBase](items/pocketbase-storage.md) — Хранилище, учётные записи и веб-панель нужны все три, и SQLite с goqu не даёт ни второго, ни третьего. +- [🔬 Выбор фреймворка для приложения](items/spa-framework-choice.md) — Конвенция веб-UI написана под htmx, а решено делать SPA: до выбора фреймворка не заводится ни сборка, ни первый экран. +- [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует. +- [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет. - [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата. diff --git a/tasks/ROADMAP.md b/tasks/ROADMAP.md index 1fe4cf4..ff1cd50 100644 --- a/tasks/ROADMAP.md +++ b/tasks/ROADMAP.md @@ -22,8 +22,9 @@ ## Запланировано -- [🎯 Записи загружаются и читаются в браузере](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем. +- [🎯 Записи загружаются и читаются в приложении, которое ставится на телефон](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем. - [🎯 Сервисом пользуются несколько человек, и записи одного не видны другому](items/multi-user.md) — У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки. +- [🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым](items/ready-notification.md) — Расшифровка занимает минуты, и всё это время человек либо смотрит на экран с опросом статуса, либо забывает вернуться. ## Направления diff --git a/tasks/items/installable-pwa.md b/tasks/items/installable-pwa.md new file mode 100644 index 0000000..d3135ad --- /dev/null +++ b/tasks/items/installable-pwa.md @@ -0,0 +1,45 @@ +# ✨ Сделать приложение устанавливаемым на телефон + +- **Тип:** feature +- **Категория:** Инфра +- **Зачем:** Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет. +- **Теги:** goal:web-access + +Двигает пункты 5 и 6 «Завершения» цели: приложение ставится с телефона и +запускается с ярлыка без адресной строки, а открытое без сети показывает это +состоянием. + +Берётся после того, как есть что ставить, — то есть после +`records-list-screen`. + +## Затрагивает + +- манифест приложения: имя, иконки под размеры экранов, цвета, режим показа; +- service worker: регистрация, обновление версии, поведение без сети; +- раздача манифеста и service worker с правильными заголовками и без длинного + кэша; +- версионирование собранной статики, чтобы установленное приложение обновлялось; +- `docs/conventions/web-ui.md` — раздел про статику и кэш; +- `docs/security.md` — service worker перехватывает запросы, и это новая + поверхность. + +## Критерии приёмки + +- Приложение предлагается к установке и после установки запускается с ярлыка на + отдельном экране, без адресной строки. Оракул — проверка на телефоне и + сверка манифеста инструментом браузера. +- Открытое без сети приложение показывает состояние «связи нет», а не пустую + страницу и не ошибку браузера. Оракул — тест либо ручная проверка с + выключенной сетью. +- Новая выкладка доходит до установленного приложения: старая статика из кэша не + переживает обновление. Оракул — установка, выкладка изменённой версии, + перезапуск приложения показывает новую. +- Service worker не кэширует ответы API и не отдаёт чужие данные после смены + сессии. Оракул — тест: после выхода и входа другой учётной записью список + записей чужих не содержит. + +## Рамки + +Офлайн-чтения готовых расшифровок и очереди отправки без сети **не делаем** — +это за границей цели. Web Push не делаем: уведомления идут через apprise и ntfy, +цель `ready-notification`. diff --git a/tasks/items/json-api-for-spa.md b/tasks/items/json-api-for-spa.md new file mode 100644 index 0000000..d6ba5a9 --- /dev/null +++ b/tasks/items/json-api-for-spa.md @@ -0,0 +1,44 @@ +# ✨ Свести приём и чтение записей к одному контракту для приложения + +- **Тип:** feature +- **Категория:** Ядро +- **Зачем:** Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем. +- **Теги:** goal:web-access + +Двигает пункты 1, 2 и 4 «Завершения» цели: экраны заводят задачу, видят её +состояние и листают список — всё через один контракт. + +Обработчик `GET /api/status/:id` сегодня отвечает `404` на **любую** ошибку +чтения, включая сбой базы, а `POST /api/audio` — `500` на любую ошибку заведения, +включая негодный файл. Экран, построенный на таком контракте, показывает «не +найдено» при упавшей базе. + +## Затрагивает + +- `POST /api/audio` и `GET /api/status/:id` — коды ответа и форма ошибки; +- новый эндпоинт списка своих записей с постраничным чтением; +- единая точка отображения доменной ошибки в код ответа — её сегодня нет + ([conventions/errors.md](../../docs/conventions/errors.md)); +- `internal/contract` — доменные ошибки под отображение; +- `internal/controller/http` целиком; +- `docs/architecture.md`, раздел «Единые точки проекта». + +## Критерии приёмки + +- Сбой базы при чтении задачи даёт `500`, а не `404`. Оракул — тест с + репозиторием, возвращающим ошибку драйвера. +- Негодный файл даёт `400` с человекочитаемым текстом, а не `500`. Оракул — + тест: файл, который отвергает разбор метаданных. +- Тело ошибки одной формы на всех эндпоинтах и не содержит сырого `err.Error()`. + Оракул — тест на четырёх ветвях отказа: форма совпадает, текста внутренней + ошибки в теле нет. +- Список записей отдаётся страницами и упорядочен по времени создания. Оракул — + тест на выборке больше страницы. +- Отображение ошибки живёт в одной функции, и она названа в + `docs/architecture.md`. Оракул — `task gate`, шаг `docs.py check`. + +## Рамки + +Аутентификацию и владельца не заводим — это `oidc-login` и `record-ownership`; +задача про форму контракта. Публичный контракт после мерджа обратной правкой не +откатывается. diff --git a/tasks/items/ntfy-delivery.md b/tasks/items/ntfy-delivery.md new file mode 100644 index 0000000..c9143d8 --- /dev/null +++ b/tasks/items/ntfy-delivery.md @@ -0,0 +1,46 @@ +# ✨ Отправлять готовый текст через apprise и ntfy + +- **Тип:** feature +- **Категория:** Ядро +- **Зачем:** Пользователь веба узнаёт о готовности только опросом с открытого экрана. +- **Теги:** goal:ready-notification + +Двигает все пять пунктов «Завершения» цели: готовый текст и отказ доходят до +пользователя веба без открытого приложения, адрес канала свой у каждого, а отказ +канала задачу не роняет. + +Сегодня `completeJob` и `failJob` отвечают только источнику `telegram`; +источник `api` не получает ничего. Здесь появляется второй способ доставки, и +выбор между ними обязан остаться в одной точке — той же, что сейчас. + +## Затрагивает + +- `internal/contract` — интерфейс отправителя уведомления; +- `internal/service`, `completeJob` и `failJob` — выбор канала по источнику + задачи; +- новый адаптер поверх apprise либо прямого HTTP к ntfy; +- адрес канала у учётной записи: колонка и её миграция, экран настройки; +- секция конфигурации: адрес сервера ntfy, способ вызова apprise; +- `docs/architecture.md` — новая внешняя зависимость и чем она отказывает; +- `docs/security.md` — текст расшифровки уходит на внешний сервис; +- `docs/database.md` — настройки числами. + +## Критерии приёмки + +- Задача из веба, дошедшая до `done`, отправляет текст в канал владельца. + Оракул — тест с подставным отправителем: вызов ровно один, с текстом задачи и + адресом владельца. +- Задача из Telegram по-прежнему отвечает ботом и вторым каналом не дублируется. + Оракул — тот же тест на источнике `telegram`: подставной отправитель ntfy не + вызван. +- Недоступный канал уведомлений не мешает задаче завершиться: состояние `done` и + текст в приложении остаются. Оракул — тест с отправителем, возвращающим + ошибку: задача в `done`, текст на месте, в логе одна запись уровня `WARN`. +- Отказ задачи доходит тем же каналом и тем же человекочитаемым текстом, что + видит пользователь Telegram. Оракул — тест на ветке `failJob`. + +## Рамки + +Web Push с VAPID и своим хранением подписок не делаем. Своего сервера ntfy не +поднимаем — адрес приходит конфигом. Текст расшифровки уходит на внешний сервис, +и это сдвиг периметра: строка в `docs/security.md` обязательна. diff --git a/tasks/items/oidc-login.md b/tasks/items/oidc-login.md new file mode 100644 index 0000000..f306645 --- /dev/null +++ b/tasks/items/oidc-login.md @@ -0,0 +1,50 @@ +# ✨ Пускать в приложение только после входа через OIDC + +- **Тип:** feature +- **Категория:** Ядро +- **Зачем:** HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору. +- **Теги:** goal:multi-user, question + +Двигает пункт 1 «Завершения» цели: неаутентифицированный запрос к записям не +проходит ни к странице, ни к API. + +Провайдер — Authelia по OIDC. Своей регистрации и своих паролей не делаем, это +граница из [паспорта](../../docs/passport.md). Разграничения записей по владельцу +здесь ещё нет: после входа видно всё, что видно сейчас, — этим занимается +`record-ownership`. + +## Затрагивает + +- новые эндпоинты входа и выхода, обработка ответа провайдера; +- `POST /api/audio` и `GET /api/status/:id` — оба уходят за аутентификацию; +- `GET /health` и `GET /metrics` — решить и записать, остаются ли открытыми; +- хранение сессии: таблица или подписанная кука; +- секция конфигурации под провайдера: адрес, идентификатор клиента, секрет; +- `docs/security.md` — периметр меняется, и первая его строка перестаёт быть + верной; +- `config.dist.toml` и `internal/config`. + +## Критерии приёмки + +- Запрос к `POST /api/audio` и `GET /api/status/:id` без сессии получает отказ, а + не заводит задачу и не отдаёт текст. Оракул — тест на обоих эндпоинтах без + куки: код ответа 401 либо 302 на вход, тело без данных задачи. +- Сессия переживает перезапуск приложения. Оракул — тест: запрос с прежней кукой + после пересоздания сервера проходит. +- Выход из сессии закрывает доступ. Оракул — тест: после выхода тот же запрос + получает отказ. +- Секрет провайдера не попадает ни в лог, ни в ответ. Оракул — тест на отсутствие + значения секрета в записанном выводе логгера. +- Первая строка `docs/security.md` описывает новый периметр. Оракул — `task + gate`, шаг `docs.py check`. + +## Рамки + +Владельца у записи здесь не заводим и выборку не сужаем: после входа видно +столько же, сколько сейчас. Инвариант «бот отвечает только тем, кто в белом +списке» не трогаем — он живёт до `telegram-account-link`. + +## Вопросы + +Остаются ли `GET /health` и `GET /metrics` открытыми? Прокси и система сбора +метрик сессии не имеют, но и наружу их отдавать незачем. diff --git a/tasks/items/ready-notification.md b/tasks/items/ready-notification.md new file mode 100644 index 0000000..34d8751 --- /dev/null +++ b/tasks/items/ready-notification.md @@ -0,0 +1,25 @@ +# 🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым + +- **Тип:** goal +- **Секция:** Запланировано +- **Зачем:** Расшифровка занимает минуты, и всё это время человек либо смотрит на экран с опросом статуса, либо забывает вернуться. + +Задача уходит в работу — человек закрывает приложение и получает сообщение, +когда текст готов. Пользователь Telegram это уже имеет: бот отвечает сам. +Пользователь веба — нет. + +Доставку берём внешнюю: apprise как отправитель, ntfy как канал. Web Push с +VAPID и собственным хранением подписок за целью **не стоит** — это отдельная +инфраструктура ради того же результата. + +## Завершение + +1. Готовый текст доходит до пользователя веба сообщением, без открытого + приложения. +2. Отказ задачи доходит тем же путём и тем же человекочитаемым текстом, что + видит пользователь Telegram. +3. Адрес канала уведомлений задаёт пользователь, а не общий конфиг: у каждого + свой. +4. Недоступность канала уведомлений не роняет задачу и не мешает ей завершиться: + текст остаётся в приложении. +5. Пользователь Telegram получает ответ по-прежнему ботом, а не вторым каналом. diff --git a/tasks/items/record-ownership.md b/tasks/items/record-ownership.md new file mode 100644 index 0000000..d815067 --- /dev/null +++ b/tasks/items/record-ownership.md @@ -0,0 +1,38 @@ +# ✨ Привязать запись к владельцу и отдавать только свои + +- **Тип:** feature +- **Категория:** Ядро +- **Зачем:** У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать. +- **Теги:** goal:multi-user + +Двигает пункт 2 «Завершения» цели: выборка чужой записи по её идентификатору +возвращает «не найдено», а не содержимое. + +Берётся после `oidc-login`: до входа неизвестно, кто владелец. + +## Затрагивает + +- таблица задач и таблица файлов — колонка владельца и её миграция; +- `internal/contract`, интерфейсы репозиториев: чтение сужается владельцем; +- `internal/adapter/repo/sqlite` — все четыре запроса задач; +- `internal/service`, оба метода заведения задачи; +- `GET /api/status/:id` — ответ на чужую запись; +- `docs/database.md` — схема и правило выборки. + +## Критерии приёмки + +- Запрос чужой записи по её идентификатору возвращает «не найдено», а не + содержимое и не «доступ запрещён». Оракул — тест: две сессии, задача первой + запрашивается второй, ответ 404 и пустое тело. +- Запись, заведённая из веба, принадлежит вошедшему; заведённая ботом — + пользователю, за которым закреплён чат. Оракул — тест на оба входа со сверкой + колонки владельца. +- Выборка воркера владельцем **не** сужается: конвейер обрабатывает записи всех. + Оракул — тест: задачи двух владельцев проходят конвейер одним воркером. +- База заводится с чистого листа, колонка владельца обязательна и без умолчания. + Оракул — прогон миграций на пустой базе и попытка вставки без владельца. + +## Рамки + +Совместного доступа, ролей и передачи записи другому не делаем: владелец один и +неизменяем. Данные не переносим — чистый лист. diff --git a/tasks/items/records-list-screen.md b/tasks/items/records-list-screen.md new file mode 100644 index 0000000..64134db --- /dev/null +++ b/tasks/items/records-list-screen.md @@ -0,0 +1,37 @@ +# ✨ Сделать экран списка своих записей и чтения текста + +- **Тип:** feature +- **Категория:** Ядро +- **Зачем:** Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем. +- **Теги:** goal:web-access + +Двигает пункты 3 и 4 «Завершения» цели: список своих записей открывается и +листается, а готовый текст читается и копируется целиком. + +Берётся после `upload-and-status-screen`. + +## Затрагивает + +- новый экран списка и экран одной записи; +- постраничное чтение из контракта API; +- копирование текста в буфер обмена; +- показ записи, которая ещё в работе, — тем же состоянием, что на экране + загрузки. + +## Критерии приёмки + +- Список показывает записи вошедшего, новые сверху, и листается дальше первой + страницы. Оракул — тест экрана на подставном API с двумя страницами. +- Текст готовой записи открывается целиком, без деления на части. Оракул — тест + на записи с текстом длиннее предела сообщения Telegram: на экране весь текст. +- Текст копируется одним действием. Оракул — тест: после действия в буфере обмена + тот же текст. +- Запись в работе показывается состоянием и доходит до текста без перезагрузки + экрана. Оракул — тест: подставной API отвечает `transcribe`, затем `done`; + экран переходит к тексту сам. + +## Рамки + +Поиска по тексту, переименования и удаления записей не делаем. Правки текста не +делаем — это граница из [паспорта](../../docs/passport.md): расшифровку отдаём как +есть. diff --git a/tasks/items/spa-framework-choice.md b/tasks/items/spa-framework-choice.md new file mode 100644 index 0000000..d77aaa8 --- /dev/null +++ b/tasks/items/spa-framework-choice.md @@ -0,0 +1,34 @@ +# 🔬 Выбор фреймворка для приложения + +- **Тип:** research +- **Категория:** Инфра +- **Зачем:** Конвенция веб-UI написана под htmx, а решено делать SPA: до выбора фреймворка не заводится ни сборка, ни первый экран. +- **Теги:** goal:web-access + +Конвенция [web-ui.md](../../docs/conventions/web-ui.md) описывала htmx с прямым +запретом на шаг сборки и реактивные фреймворки. Решение сменилось на SPA, и +теперь эта конвенция ждёт переписывания — но написать её не под что, пока +фреймворк не назван. + +Разведка, а не задача: исход — знание и переписанная конвенция, а не работающий +экран. + +## Вопрос + +Какой фреймворк берём под приложение на четыре экрана, которое собирается в +статику, вшивается в бинарник Go и ставится на телефон как PWA. + +## Куда ляжет ответ + +`docs/research/spa-framework.md` — сравнение с числами: размер собранной статики, +время холодной сборки, число зависимостей. Выбор и причина отказа от остальных — +в `docs/adr/`, потому что переделка на другой фреймворк стоит дороже переписывания +одного файла. Следом переписывается `docs/conventions/web-ui.md`, и до тех пор +она стоит честной строкой «фреймворк не выбран». + +## Рамки + +Сравнение идёт на одном и том же пробном экране: список записей с опросом +состояния. Кандидаты названы человеком; сегодня в разговоре звучали Svelte, Vue +и React, все с Vite. Мера — размер статики, простота вшивания в `go:embed` и +цена шага сборки в гейте, а не популярность. diff --git a/tasks/items/spa-skeleton.md b/tasks/items/spa-skeleton.md new file mode 100644 index 0000000..94a83b5 --- /dev/null +++ b/tasks/items/spa-skeleton.md @@ -0,0 +1,43 @@ +# ✨ Собрать каркас приложения и раздать его из бинарника + +- **Тип:** feature +- **Категория:** Инфра +- **Зачем:** Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует. +- **Теги:** goal:web-access + +Двигает «Завершение» цели опосредованно: сам по себе каркас пользователю ничего +не даёт, но без него ни один экран не соберётся. Видимая польза — приложение +открывается и показывает, что оно живо и кто вошёл. + +Берётся после `spa-framework-choice`: собирать не на чем, пока фреймворк не +выбран. + +## Затрагивает + +- новый каталог фронтенда: исходники, зависимости, конфигурация сборки; +- `Taskfile.yml` — шаг сборки статики и его место в `task gate` и `task image`; +- `Dockerfile` — сборка статики внутри образа, чтобы выкладка не зависела от + машины разработчика; +- раздача статики из бинарника через `go:embed` и роут под неё; +- `.gitignore` — каталог зависимостей и собранная статика; +- `docs/conventions/web-ui.md` — переписывается под выбранный фреймворк; +- `docs/architecture.md` — новый компонент и новая внешняя зависимость сборки. + +## Критерии приёмки + +- Статика собирается одной командой и вшивается в бинарник: запущенный бинарник + отдаёт приложение без каталога рядом. Оракул — сборка, запуск бинарника из + пустого каталога, запрос корня возвращает разметку приложения. +- Образ собирается на чистой машине без предустановленного окружения фронтенда. + Оракул — `task image` без локально установленных зависимостей фронтенда. +- Приложение показывает, кто вошёл, и обращается к API живого сервера. Оракул — + тест либо ручная проверка на запущенном сервере с сессией. +- Шаг сборки статики входит в `task gate` и краснеет при ошибке сборки. Оракул — + намеренно сломанный исходник роняет `task gate`. + +## Рамки + +Экранов, кроме заглушки с именем вошедшего, не делаем — это `upload-and-status-screen` +и `records-list-screen`. Установку на телефон не делаем — это `installable-pwa`. +Появление шага сборки в гейте меняет требования к машине разработчика: это +решение уровня ADR, и оно записывается. diff --git a/tasks/items/telegram-account-link.md b/tasks/items/telegram-account-link.md new file mode 100644 index 0000000..9578550 --- /dev/null +++ b/tasks/items/telegram-account-link.md @@ -0,0 +1,43 @@ +# ✨ Сопоставить пользователя Telegram с учётной записью + +- **Тип:** feature +- **Категория:** Ядро +- **Зачем:** Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны. +- **Теги:** goal:multi-user + +Двигает пункты 4 и 5 «Завершения» цели: записи из бота видны тому же человеку в +приложении, а белый список перестаёт быть отдельным механизмом — право писать +боту выводится из учётной записи. + +Берётся после `record-ownership`: связывать не с чем, пока у записи нет +владельца. + +## Затрагивает + +- связь учётной записи с чатом Telegram: таблица и её миграция; +- команда бота для привязки и способ её подтвердить; +- `internal/controller/tg`, проверка отправителя — сегодня `slices.Contains` по + строке автора; +- ключ конфигурации `[server] users_while_list` — он уходит; +- `config.dist.toml`, `internal/config`; +- `docs/security.md`, раздел «Что разграничивает доступ»; +- инвариант «Бот отвечает только тем, кто в белом списке» в `CLAUDE.md`. + +## Критерии приёмки + +- Сообщение от непривязанного чата обрабатывать не начинаем и файл не скачиваем. + Оракул — тест: обновление от неизвестного чата не заводит задачу и не ходит в + сеть. +- Привязка идёт по идентификатору чата, а не по имени пользователя: смена имени + в Telegram доступа не меняет. Оракул — тест: то же обновление с изменённым + `From.UserName` по-прежнему принимается. +- Запись, пришедшая ботом, видна тому же человеку в приложении. Оракул — тест: + задача из привязанного чата читается сессией связанной учётной записи и не + читается чужой. +- Ключ `users_while_list` из конфига и из документов убран. Оракул — `task + gate`, шаг `docs.py check`, и `grep users_while_list` по репозиторию пуст. + +## Рамки + +Одна учётная запись — один чат: нескольких чатов на человека не заводим. +Инвариант в `CLAUDE.md` переписывается этой же задачей, а не остаётся врать. diff --git a/tasks/items/upload-and-status-screen.md b/tasks/items/upload-and-status-screen.md new file mode 100644 index 0000000..40bd070 --- /dev/null +++ b/tasks/items/upload-and-status-screen.md @@ -0,0 +1,38 @@ +# ✨ Сделать экран загрузки записи и её состояния + +- **Тип:** feature +- **Категория:** Ядро +- **Зачем:** Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит. +- **Теги:** goal:web-access + +Двигает пункты 1 и 2 «Завершения» цели: экран принимает файл и заводит задачу, а +её состояние обновляется само, пока задача не дошла до `done` или `failed`. + +Берётся после `spa-skeleton` и `json-api-for-spa`. + +## Затрагивает + +- новый экран приложения: выбор файла, отправка, показ состояния; +- опрос состояния задачи и его остановка; +- показ отказа человекочитаемым текстом из контракта API; +- предел размера загружаемого файла на стороне приложения и на стороне сервера + (`router.MaxMultipartMemory`, сегодня 32 МиБ); +- `docs/database.md` — частота опроса числом. + +## Критерии приёмки + +- Выбранный файл уходит на сервер и заводит задачу; экран сразу показывает её + состояние. Оракул — тест экрана на подставном API: после отправки на экране + идентификатор задачи и состояние `created`. +- Опрос сам прекращается, когда задача дошла до `done` или `failed`. Оракул — + тест: после ответа `done` новых запросов к API нет. +- Отказ задачи показывается человекочитаемым текстом, а не кодом и не сырой + ошибкой. Оракул — тест на ответе с состоянием `failed`. +- Файл больше предела отклоняется на экране до отправки, с названным числом. + Оракул — тест на файле сверх предела: запроса к API нет, на экране предел + числом. + +## Рамки + +Записи звука в приложении не делаем — файл выбирают в системном диалоге. Список +прошлых записей не делаем, это `records-list-screen`. diff --git a/tasks/items/web-access.md b/tasks/items/web-access.md index 37fdf79..7dab2d1 100644 --- a/tasks/items/web-access.md +++ b/tasks/items/web-access.md @@ -1,19 +1,26 @@ -# 🎯 Записи загружаются и читаются в браузере +# 🎯 Записи загружаются и читаются в приложении, которое ставится на телефон - **Тип:** goal - **Секция:** Запланировано - **Зачем:** Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем. Приложение получает поверхность, на которой запись загружают и забирают текст, -не открывая Telegram и не вызывая API руками. Конвейер обработки при этом -остаётся прежним — меняется только вход и способ показать результат. +не открывая Telegram и не вызывая API руками. Ставится оно на телефон и +открывается с ярлыка, как обычное приложение. Конвейер обработки при этом +остаётся прежним — меняется вход и способ показать результат. + +Границы взяты уже: запись звука в самом приложении и работа без сети за целью +**не стоят** — файл выбирают в системном диалоге, а без сети приложение +показывает, что связи нет. ## Завершение -1. Страница принимает файл формой и заводит задачу — ту же, что заводит бот. -2. Состояние задачи видно на странице и обновляется само, пока задача не дошла - до `done` или `failed`; отказ показывается человекочитаемым текстом. -3. Готовый текст читается и копируется со страницы целиком, без деления на - части. +1. Экран принимает файл и заводит задачу — ту же, что заводит бот. +2. Состояние задачи видно на экране и обновляется само, пока задача не дошла до + `done` или `failed`; отказ показывается человекочитаемым текстом. +3. Готовый текст читается и копируется с экрана целиком, без деления на части. 4. Список своих записей открывается и листается. -5. Всё перечисленное работает без JavaScript — формой и переходом по ссылке. +5. Приложение ставится на телефон из браузера и запускается с ярлыка на + отдельном экране, без адресной строки. +6. Открытое без сети, приложение показывает это состоянием, а не пустой + страницей и не ошибкой браузера.