--- компонент: ADA (AI Delivery Application) — PWA статус: черновик v3 обновлено: 2026-08-28 решение: делаем PWA, нативную сборку не делаем источники: канон COBRA (Ада), API Кобры (direct-чаты), WebKit/Apple по Web Push и Badging связано: [[chesuyka]] — карманный вебапп и раскладки --- # ADA как PWA ## Что это **ADA (AI Delivery Application) — наше мобильное приложение.** По канону — младшая сестра Алины: «дублирует весь функционал — агенты, чаты, артефакты, уведомления. Общая память с Алиной: начали в чате — продолжили в аппе с того же места». **Решение: делаем её как PWA.** Нативной сборки, магазинов и сертификатов не будет. ## Что такое PWA PWA (Progressive Web App) — это **обычный сайт, который умеет вести себя как приложение**. Никакого особого языка и никакой особой сборки: тот же HTML, CSS и JavaScript, которые уже работают в карманном вебаппе. Приложением его делают три вещи, которые к сайту добавляют: | Часть | Что даёт | |---|---| | **Манифест** — файл с описанием приложения | имя, иконка, цвета, стартовый экран. Из-за него сайт можно «поставить» на телефон | | **Service worker** — скрипт, живущий рядом со страницей | работа без сети, кэш, приём push-уведомлений в фоне | | **HTTPS** | без него не работает ни то, ни другое | Пользователь открывает адрес в браузере, нажимает «добавить на домашний экран» — и получает иконку рядом с остальными приложениями. Открывается оно на весь экран, без адресной строки, и внешне неотличимо от обычного приложения. ## Почему именно это нам подходит Три задачи, ради которых всё затевалось, PWA закрывает без борьбы: 1. **Обход магазинов** — здесь его просто нет. Нет Apple, нет сертификатов, нет ревью, нет лимита в 100 устройств, нет риска отзыва. Ссылка и QR-код — вся дистрибуция. 2. **Уведомления** — Web Push работает и на Android, и на iOS (с 16.4, при установке на домашний экран). 3. **Иконка заказчика** — и вот здесь главный выигрыш. В нативном приложении иконка зашита в сборку и меняется только пересборкой. **В PWA манифест отдаёт сервер** — значит имя, иконка и цвета становятся обычными настройками, разными для каждого заказчика, без единой пересборки. Плюс обновления: новая версия доезжает до всех при следующем открытии. Никаких «половина парка сидит на сборке трёхмесячной давности». ## Чем платим Честно и без прикрас: | Чего нет | Насколько больно | |---|---| | Автоматического предложения установки на iOS | придётся объяснять пользователю: «Поделиться → На экран „Домой“». Это главный барьер | | Фоновой работы (Background Sync) на iOS | нельзя дослать что-то, пока приложение закрыто | | Гарантии, что подписка на push переживёт простой | на iOS подписки иногда молча протухают — лечится перепроверкой при каждом запуске | | Запаса по хранилищу | iOS жёстче с квотами; тяжёлый офлайн-кэш не потянуть | | Магазина как витрины | но нам он и не был нужен | **Биометрия при этом есть** — через passkeys (WebAuthn) вход по Face ID и отпечатку работает и в вебе. Это не потеря. ## Можно ли сделать PWA из Mini App в Телеграме Короткий ответ: **сам Mini App — нет, но тот же код одновременно работает и как PWA.** Внутри Телеграма приложение живёт не в браузере, а в его собственном WebView, и там: | | Как есть | |---|---| | Service worker | **на iOS не работает вообще** (WKWebView). На Android и macOS регистрируется — то есть поведение неодинаковое, а это хуже, чем никакого | | Манифест | Телеграм его игнорирует | | Установка | `beforeinstallprompt` не срабатывает, «на экран „Домой“» изнутри недоступно | | Web Push | нет: разрешение не запросить, уведомления в Телеграме доставляет бот | Без service worker'а нет ни офлайна, ни Web Push — то есть ровно того, ради чего PWA и делается. **Правильная схема — одна кодовая база, два входа:** ``` чат в Телеграме → Mini App (telegram-web-app.js, подпись initData) свой адрес → PWA (манифест, service worker, установка, Web Push) ``` Это то же самое разделение хостов, что и у чешуйки: экраны общие, различаются только мост и способ уведомлений. Практические правила, чтобы одно не мешало другому: ```js const inTelegram = Boolean(window.Telegram?.WebApp?.initData); if (!inTelegram) { navigator.serviceWorker.register('/sw.js'); // внутри Телеграма — не регистрировать showInstallUi(); // кнопку и iOS-инструкцию тоже } // Notification.requestPermission() внутри Телеграма не вызывать: // там уведомления шлёт бот ``` ### Иконка от Телеграма — не то же самое С Bot API 8.0 у Mini App есть свой `addToHomeScreen()`: иконка на домашнем экране появляется, но **запускает Mini App внутри Телеграма**, а не наше приложение. По сути это ярлык на Телеграм, и уведомления по-прежнему приходят от бота. Полезно для тех, кто и так живёт в мессенджере: дешёвый способ дать иконку без установки PWA. Но заменой ADA это не является — своё хранилище, свой офлайн и свои push остаются только у PWA. --- # Как создать ## Шаг 1. Один домен и HTTPS PWA живёт на одном происхождении. Всё — страницы, манифест, service worker, иконки — отдаётся с одного домена по HTTPS. Сертификат обязателен: без него не зарегистрируется service worker, а без него нет ни офлайна, ни push. Service worker кладётся **в корень** (`/sw.js`), иначе он будет управлять только своей папкой. ## Шаг 2. Манифест Файл, из которого телефон узнаёт, как называется приложение и как выглядит. ```json { "id": "/app/", "name": "ADA — AI Delivery", "short_name": "ADA", "start_url": "/app/?src=pwa", "scope": "/app/", "display": "standalone", "orientation": "portrait", "background_color": "#efeee9", "theme_color": "#0e9d7d", "lang": "ru", "icons": [ { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }, { "src": "/icons/maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "shortcuts": [ { "name": "Клиенты", "url": "/app/inbox" }, { "name": "Коллеги", "url": "/app/team" } ] } ``` Что здесь важно: - **`display: standalone`** — открывается без адресной строки, как приложение. - **`scope`** — граница приложения. Ссылка за её пределы откроется в обычном браузере. - **`start_url`** — экран запуска. Параметр `?src=pwa` пригодится, чтобы отличать в аналитике запуск с иконки от захода по ссылке. - **`id`** — постоянная личность приложения. **Не менять никогда**: сменили — телефон посчитает это другим приложением и поставит вторую иконку. - **`maskable`-иконка** — чтобы Android не рисовал белую рамку вокруг вашей. Подключается в `` каждой страницы: ```html ``` `apple-touch-icon` размером 180×180 оставить обязательно — iOS исторически берёт иконку именно оттуда. `viewport-fit=cover` вместе с `env(safe-area-inset-*)` в CSS не даст интерфейсу залезть под «чёлку» и под полосу жестов. ## Шаг 3. Иконки и брендинг заказчика Готовим три файла: **192×192**, **512×512** и отдельную **maskable** 512×512 с запасом полей по краям (в маску попадает примерно центральная часть). Плюс 180×180 для iOS. **Манифест отдаём динамически** — это и есть решение задачи «своя иконка у заказчика»: ``` GET /manifest.webmanifest → сервер смотрит, чей это домен или пространство, и подставляет имя, иконки и цвета этого заказчика ``` Тогда настройки ADA становятся обычными полями в админке: загрузили логотип — у нового пользователя приложение ставится уже с ним. ⚠️ **Одна оговорка, о которой надо помнить.** Иконка запоминается **в момент установки**. Заказчик, поменявший логотип, увидит его на домашнем экране только после переустановки — у уже установленных иконка останется прежней. Внутри приложения (шапка, сплэш, цвета) всё меняется сразу. Поэтому в админке рядом с загрузкой логотипа нужна честная подпись: «новая иконка появится у тех, кто установит приложение после этого; остальным — при переустановке». ## Шаг 4. Service worker Минимальный, но рабочий: кэширует оболочку, отдаёт офлайн-страницу и умеет обновляться. ```js // /sw.js const V = 'ada-v7'; // менять при каждом релизе const SHELL = ['/app/', '/app/offline.html', '/css/app.css', '/js/app.js']; self.addEventListener('install', (e) => { e.waitUntil(caches.open(V).then((c) => c.addAll(SHELL))); }); self.addEventListener('activate', (e) => { e.waitUntil( caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== V).map((k) => caches.delete(k))) ).then(() => self.clients.claim()) ); }); self.addEventListener('fetch', (e) => { const { request } = e; if (request.method !== 'GET') return; // отправку сообщений не кэшируем // API — только из сети, данные должны быть свежими if (new URL(request.url).pathname.startsWith('/api/')) return; // Остальное — из кэша, с тихим обновлением в фоне e.respondWith( caches.match(request).then((hit) => { const net = fetch(request) .then((res) => { caches.open(V).then((c) => c.put(request, res.clone())); return res; }) .catch(() => caches.match('/app/offline.html')); return hit || net; }) ); }); ``` **Правило первое: чаты и любые данные не кэшировать.** Показанное вчерашнее сообщение хуже, чем честное «нет сети». **Правило второе: научить обновляться.** Новый service worker по умолчанию ждёт, пока закроются все вкладки — а в приложении их закрывают редко. Поэтому в приложении: ```js navigator.serviceWorker.register('/sw.js').then((reg) => { reg.addEventListener('updatefound', () => { const sw = reg.installing; sw.addEventListener('statechange', () => { if (sw.state === 'installed' && navigator.serviceWorker.controller) { showUpdateBanner(() => { sw.postMessage('skip-waiting'); location.reload(); }); } }); }); }); ``` Плашка «Есть обновление — обновить» вместо тихой подмены: пользователь не теряет то, что печатал. ## Шаг 5. Установка **Android.** Браузер сам предложит установку, но лучше вести пользователя своей кнопкой: ```js let deferred; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; showInstallButton(); // своя кнопка «Установить ADA» }); installButton.onclick = async () => { deferred.prompt(); await deferred.userChoice; deferred = null; hideInstallButton(); }; ``` **iOS.** Программной установки нет и не предвидится: `beforeinstallprompt` там не срабатывает. Единственный путь — **Safari → «Поделиться» → «На экран „Домой“»**, руками. Значит нужен экран-инструкция с картинками, который показывается, если мы на iOS и не в standalone-режиме: ```js const iOS = /iphone|ipad|ipod/i.test(navigator.userAgent); const installed = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; if (iOS && !installed) showIosInstallGuide(); ``` Отдельно предупредить: **в Chrome на iPhone установка не работает** — только Safari. Это ловит половину пользователей, и лучше сказать заранее, чем разбираться потом. ## Шаг 6. Уведомления ### Как устроено Браузер выдаёт устройству персональный адрес (endpoint), на который сервер шлёт сообщение, подписанное парой ключей **VAPID**. Service worker будит и показывает уведомление. Ключи VAPID генерируются один раз (например библиотекой `web-push`): публичный уезжает в браузер, приватный остаётся на сервере. ### На клиенте ```js async function subscribe() { if (Notification.permission !== 'granted') { // ВАЖНО: спрашивать только по нажатию кнопки, не при загрузке const p = await Notification.requestPermission(); if (p !== 'granted') return null; } const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC_KEY, }); await fetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sub), }); return sub; } ``` ### В service worker ```js self.addEventListener('push', (e) => { const d = e.data.json(); e.waitUntil( self.registration.showNotification(d.title, { body: d.body, icon: '/icons/192.png', badge: '/icons/badge.png', tag: d.chatId, // сообщения одного чата схлопываются в одно data: { url: d.url }, }) ); }); self.addEventListener('notificationclick', (e) => { e.notification.close(); e.waitUntil(clients.openWindow(e.notification.data.url)); }); ``` `userVisibleOnly: true` — не прихоть, а требование: на каждый push обязательно показать уведомление. Тихие push для фоновой синхронизации не разрешены. ### Правила, без которых ничего не работает 1. **Разрешение спрашивать по кнопке и после того, как человек понял ценность.** Запрос при первой загрузке — гарантированный отказ, а отказ на iOS переспросить почти невозможно. 2. **На iOS сначала установка, потом push.** В обычной вкладке Safari уведомления не работают вообще — только у приложения с домашнего экрана. Значит порядок один: поставил → открыл с иконки → разрешил уведомления. 3. **Подписку перепроверять при каждом запуске.** На iOS подписки умеют молча протухать после простоя. Лечится просто: на старте сверить текущую подписку с сохранённой на сервере и переподписаться, если её нет. ## Шаг 7. Бейдж на иконке Число непрочитанного, работает и на iOS (16.4+), но **только если разрешены уведомления**: ```js if ('setAppBadge' in navigator) { unread > 0 ? navigator.setAppBadge(unread) : navigator.clearAppBadge(); } ``` ## Шаг 8. Вход и биометрия ⚠️ **Откуда PWA берёт личность.** Внутри мессенджера её даёт `initData` из контейнера. У PWA контейнера нет — значит остаётся второй путь платформы: параметр **`?t=<токен>`** в адресе, который обменивается на доверенную пару через `POST /api/webapp/exchange`. Про этот токен важно знать две вещи: - **TTL 7 дней, скользящий** — обновляется при каждом успешном обмене. Человек, не заходивший неделю, придёт с мёртвым токеном, и его надо честно встретить экраном входа, а не белым полем. - **Расходуется неразрушающе** — по одной ссылке можно зайти много раз. Отсюда следствие для манифеста: **`start_url` не должен содержать `?t=`.** Токен запечётся в иконку при установке и через неделю протухнет навсегда. Сессию держим своими средствами, а `?t=` используем только на первом входе. Токен сессии хранить в `httpOnly`-куке — из JavaScript её не достать, а значит не украдут скриптом. Если нужен доступ из кода — IndexedDB, но с коротким сроком жизни и обязательным обновлением. Быстрый вход возвращаем через **passkeys** (WebAuthn): человек прикладывает палец или смотрит в камеру, и это тот же Face ID, что в нативных приложениях. Работает в Safari и Chrome, пароль вводить не нужно. ## Шаг 9. Настройки ADA Как SSI у чешуйки: заполняются при развёртывании, правятся потом. | Группа | Что настраивается | Когда применяется | |---|---|---| | **Внешний вид** | имя, иконки, сплэш, `theme_color`, логотип в шапке | иконка и имя — при установке; остальное сразу | | **Подключение** | домен, рабочее пространство, `scope` и `start_url` | сразу | | **Состав** | какие разделы включены: чешуйки, клиенты, коллеги, артефакты | сразу | | **Уведомления** | что шлём push-ом, тихие часы, звук, поведение бейджа | сразу | | **Вход** | passkeys, срок сессии, запрашивать ли повторный вход | сразу | | **Push-канал** | пара ключей VAPID | при выпуске | --- # Переписка с коллегами **Строить с нуля не нужно — механизм уже есть в API Кобры:** | Инструмент | Что делает | Скоуп | |---|---|---| | `cobra_list_direct_chats` | внутренние чаты с коллегами: последнее сообщение, счётчик непрочитанного | `chats:read` | | `cobra_send_direct_message` | отправить текст коллеге от имени владельца токена | `chats:write` | Корпоративный мессенджер в ADA — **витрина существующего механизма**. Не нужны своя база сообщений, своя доставка, свои прочтения. ### Две ленты, которые нельзя путать - **Коллеги** — внутренние direct-чаты: обсудить клиента, переспросить, договориться. - **Клиенты** — чаты Кобры из Telegram, Avito, MAX: входящие от людей, которым мы отвечаем. Разделять жёстко: разные вкладки, разный цвет шапки, подтверждение перед первой отправкой клиенту в сессии. Самая дорогая ошибка мобильного инбокса — ответ коллеге, улетевший клиенту. ### Чего в API пока нет Групповых внутренних чатов (только один на один), вложений в них (`cobra_send_direct_message` принимает только текст), отметок о прочтении. Это вопросы к ядру — не повод строить свою переписку рядом. ### Что слать в push Уведомление — это адрес, а не рассылка: - новое сообщение от клиента в чате, который на мне; - сообщение от коллеги; - то, что ждёт моего решения: согласование, заявка на доработку, эскалация; - срабатывание правила, где я ответственный. Всё подряд из общего инбокса слать нельзя. Приложение, которое звенит без адресата, выключают на второй день — и канал умирает вместе с уведомлениями. --- # Родня: WhatsApp У карманной раскладки есть ещё один хост, к ADA отношения не имеющий, но про него легко забыть: **WhatsApp**. Там нет ни контейнера, ни SDK — ссылка открывается в обычном браузере, платформа помечает это параметром `?platform=whatsapp`, а личность резолвится тем же `?t=`. Практически это значит, что **путь «браузер + токен», который делается ради ADA, попутно закрывает и WhatsApp**. Разница только в том, что в ADA страница установлена на домашний экран, а в WhatsApp живёт обычной вкладкой. --- # Отдельно про iOS Собрано в одном месте, потому что здесь ломается чаще всего: | Что | Как есть | |---|---| | Установка | только Safari, только вручную через «Поделиться». В Chrome на iPhone — нельзя | | Push | только у приложения с домашнего экрана, с iOS 16.4. Во вкладке Safari не работает | | Разрешение | спрашивать после установки, по нажатию. Отказ переспросить почти нельзя | | Подписки | могут молча протухнуть после простоя — перепроверять при каждом запуске | | Бейдж | работает с 16.4, но только при разрешённых уведомлениях | | Фоновая работа | Background Sync и Periodic Sync недоступны | | Хранилище | квоты жёстче, тяжёлый офлайн-кэш не потянуть | | Declarative Web Push | с Safari 18.4 есть упрощённый путь без service worker — стоит посмотреть, но основной сценарий пока строим на обычном | --- # Чек-лист - [ ] всё на одном домене и по HTTPS, `sw.js` в корне - [ ] манифест отдаётся сервером и подставляет брендинг заказчика - [ ] `id` в манифесте зафиксирован и больше не меняется - [ ] иконки 192, 512, maskable 512 и `apple-touch-icon` 180 - [ ] `viewport-fit=cover` и `env(safe-area-inset-*)` в вёрстке - [ ] API и чаты не кэшируются - [ ] есть плашка «Есть обновление» вместо тихой подмены - [ ] на Android — своя кнопка установки через `beforeinstallprompt` - [ ] на iOS — экран-инструкция и предупреждение про Safari - [ ] разрешение на уведомления спрашивается по кнопке, а не при загрузке - [ ] на iOS порядок соблюдён: установка → запуск с иконки → уведомления - [ ] подписка перепроверяется при каждом запуске - [ ] бейдж ставится и снимается - [ ] вход через passkeys, токен в `httpOnly`-куке - [ ] `start_url` не содержит `?t=` — токен не запекается в иконку - [ ] протухший токен встречает экраном входа, а не пустотой - [ ] две ленты не спутать, первая отправка клиенту подтверждается - [ ] экранов, которых нет в карманном вебаппе, — ноль # Где обычно ломается 1. **Разрешение спросили при загрузке** — отказ, и на iOS это почти навсегда. 2. **Push просят до установки** — на iOS во вкладке Safari он не работает в принципе, а пользователь уже решил, что «у вас не работает». 3. **Подписку не перепроверяют** — уведомления тихо перестают приходить через пару недель простоя, и никто не понимает почему. 4. **Сменили `id` или `scope`** — на телефоне появляется вторая иконка, а первая перестаёт обновляться. 5. **Закэшировали API** — человек видит вчерашнее сообщение и отвечает на него. 6. **Тихое обновление посреди работы** — страница перезагрузилась, набранный текст пропал. 7. **Пообещали смену иконки на лету** — она запоминается при установке, у существующих пользователей не поменяется. 8. **Забыли про Chrome на iPhone** — пользователь честно жмёт «поделиться» и не находит пункта. 9. **`?t=` в `start_url`** — токен запечён в установленную иконку и через неделю мёртв навсегда, причём у всех сразу. 10. **Второй продукт вместо раскладки** — экран, которого нет в карманном вебаппе, означает, что ADA поехала своей дорогой. # Источники - Канон COBRA: `entity-ada.html` — «мобильное приложение, дублирующее весь функционал» - API Кобры: `cobra_list_direct_chats`, `cobra_send_direct_message` - [Спецификация чешуйки](manifest-chesuyka.md) — карманная раскладка и правила поверхностей - [WebKit — Web Push for Web Apps on iOS and iPadOS](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/) - [WebKit — Badging for Home Screen Web Apps](https://webkit.org/blog/14112/badging-for-home-screen-web-apps/) - [PWA iOS Limitations and Safari Support, 2026](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) --- _Источник: `cobra-components/ada.md` в репозитории audio-talks. Эта копия обновляется скриптом `sync-components.mjs` — правки вносить в источник, не здесь._