Web App Manifest - это JSON-файл с метаданными приложения, который превращает вкладку в устанавливаемое приложение. Он задаёт две вещи: identity (кто это приложение для системы) и представление установленной копии - имя, иконки, цвета, режим отображения. Пока сайт открыт как обычная страница, манифест почти не влияет на UX; его роль раскрывается при установке и запуске с домашнего экрана.
Ключевое и часто недопонятое поле - id: это стабильный идентификатор установки. Если однажды сменить id, система сочтёт это другим приложением и предложит установить его заново, рядом со старым. start_url - точка запуска, страница, которая открывается при тапе по иконке (полезно добавить метку ?source=pwa для аналитики). scope - граница app-like контекста: пока пользователь ходит внутри scope, он остаётся в оболочке приложения, а уйдя за неё, попадает в обычный браузер. Это не то же самое, что scope Service Worker, хотя на практике их согласуют.
Иконки заслуживают внимания. Кроме обычных PNG нужной размерности дайте maskable-иконку с purpose: maskable: платформа накладывает свою маску (круг, скруглённый квадрат), и важное содержимое должно умещаться в безопасной зоне - круге радиусом 40% от размера, иначе система обрежет углы логотипа. Реальные screenshots с корректными sizes, type и form_factor превращают системный диалог установки в витрину, а ошибка в MIME или размерах заставит браузер тихо проигнорировать ресурс.
Сам факт валидного манифеста не делает установку правильной. Установка - результат ценности, а не modal на первом же посещении: человек, который ещё не понял, зачем ему приложение, закроет окно и запомнит раздражение. Разумнее показывать приглашение после того, как пользователь получил пользу - создал несколько заметок, вернулся во второй раз. И главное: сайт обязан оставаться полностью полезным без установки, потому что большинство никогда не установит.
Критерии и интерфейс установки различаются между браузерами, и единого стандартного механизма нет. Chromium перед показом своего приглашения может вызвать событие beforeinstallprompt. Перехватив его, вы делаете preventDefault(), сохраняете событие и показываете собственную кнопку в нужный момент. По клику вызываете сохранённое событие: prompt() показывает системный диалог, а userChoice возвращает промис с outcome - accepted или dismissed. Событие одноразовое: после prompt() его нельзя вызвать повторно.
На iOS всё иначе: beforeinstallprompt там нет, и пользователь добавляет приложение через системное меню Поделиться - На экран Домой. Значит, кнопка на базе одного лишь beforeinstallprompt на iPhone просто не появится, и полагаться на неё как на единственный путь нельзя. Кросс-платформенное решение: пришло событие - показываем свою кнопку, нет (Safari, уже установлено) - прячем её или даём короткую инструкцию для iOS. Проверить запуск в установленном виде помогает медиазапрос display-mode: standalone.
Перед выкатом стоит пройти installability audit: манифест отдаётся с корректным Content-Type и связан тегом link rel=manifest, есть иконки нужных размеров и maskable, start_url внутри scope, id стабилен, Service Worker зарегистрирован, страница на HTTPS. Показательный провал - молчаливый: команда меняет start_url или id между релизами, у части пользователей приложение отваливается от домашнего экрана или задваивается, а в логах ничего, потому что технически это просто новая установка.
{
"id": "/notes/",
"name": "Offline Notes",
"short_name": "Notes",
"description": "Заметки, работающие без сети",
"start_url": "/notes/?source=pwa",
"scope": "/notes/",
"display": "standalone",
"background_color": "#07100f",
"theme_color": "#087f56",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512",
"type": "image/png", "purpose": "maskable" }
],
"screenshots": [
{ "src": "/shots/wide.png", "sizes": "1280x720",
"type": "image/png", "form_factor": "wide" }
],
"shortcuts": [
{ "name": "Новая заметка", "url": "/notes/new" }
]
}let deferredInstall;
window.addEventListener('beforeinstallprompt', event => {
event.preventDefault();
deferredInstall = event;
installButton.hidden = false;
});
installButton.onclick = async () => {
if (!deferredInstall) return;
deferredInstall.prompt();
const choice = await deferredInstall.userChoice;
deferredInstall = null;
installButton.hidden = true;
analytics.track('install_choice', { outcome: choice.outcome });
};