Манифест и установка на экран

PWA≈ 23 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Доска открывается без сети и обновляется, не ломая открытые вкладки. Но живёт она по-прежнему во вкладке: чтобы до неё добраться, человек ищет её среди двадцати других или набирает адрес. Предпоследний шаг главы — сделать доску приложением: иконка на экране, своё окно без адресной строки, заставка при запуске. Браузеру для этого нужен манифест — JSON-файл с описанием приложения, — а вам нужно знать, что каждая платформа ставит приложения по-своему. Разберём поля манифеста, выясним, от какого адреса считаются start_url и scope, соберём проверку по критериям Chrome и сверим её с самим Chrome, а в конце найдём ловушку, в которой поставленная на экран доска без сети открывается запасной страницей.

Разделы темы:·

Манифест — обычный JSON-файл на сервере рядом с доской. Код страницы его не читает и ничего с ним не делает: страница только сообщает браузеру адрес, одной строкой в <head> — <link rel="manifest" href="/manifest.webmanifest">. Расширение .webmanifest зарегистрировано именно для таких файлов, тип ответа у него — application/manifest+json. Сработает и .json, но так по имени сразу видно, что это за файл.

Вот манифест доски. Он лежит в той же папке board, которую мы разложили в первой статье главы, а рядом появилась папка с тремя иконками:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал
{
"name": "Доска задач",
"short_name": "Доска",
"lang": "ru",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал
icon-192.png
icon-512.png
icon-maskable-512.png

Откроем доску в чистом профиле и посмотрим журнал сервера. Сервер тот же, свой статический, что и в статье про стратегии кеша; порт другой, чтобы регистрации воркера не смешивались с прошлыми опытами.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · журнал моего статического сервера (m1 — папка стенда, где лежит board), первое открытие http://127.0.0.1:3341/ в Chrome 152 без окна; строку «===» пишет мой сценарий
=== загрузка: http://127.0.0.1:3341/ ===
127.0.0.1 - - [13/Sep/2026 11:52:21] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 11:52:21] "GET /assets/board.5c7d0a11.css HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 11:52:21] "GET /assets/app.7e4d2f90.js HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 11:52:21] "GET /api/tasks.json HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 11:52:21] "GET /manifest.webmanifest HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 11:52:21] "GET /favicon.ico HTTP/1.1" 404 -
127.0.0.1 - - [13/Sep/2026 11:52:21] "GET /icons/icon-192.png HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 11:52:21] "GET /sw.js HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 11:52:21] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 11:52:21] "GET /offline.html HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 11:52:21] "GET /assets/board.5c7d0a11.css HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 11:52:21] "GET /assets/app.7e4d2f90.js HTTP/1.1" 200 -

manifest.webmanifest браузер попросил сам, сразу за данными, и тут же скачал одну иконку, icon-192.png. Ни одной строки кода ради этого страница не выполняла. Иконка нужна для проверки установки: ниже увидим, что без подходящей иконки приложение не поставить. Манифеста нет и в оболочке воркера — и не нужно: браузер читает его при установке, а в этот момент сеть заведомо есть.

ПолеЧто задаётУ доски
name, short_nameполное имя — в окне установки и на заставке; короткое — под иконкой, где мало места«Доска задач», «Доска»
start_urlадрес, который откроется при запуске с иконки/
scopeкакие адреса считаются приложением: уйдёте за их пределы — браузер покажет, что вы уже не в приложении/
idимя приложения для браузера; без него им становится start_url, и после смены start_url это уже другое приложение/
displayfullscreen — весь экран, standalone — своё окно без адресной строки, minimal-ui — окно с минимумом кнопок, browser — обычная вкладкаstandalone
iconsкартинки с размером sizes и назначением purpose192 и 512 обычные, 512 maskable
theme_colorцвет панели над приложением; виден и в переключателе задач#2563eb
background_colorфон, пока не загрузились стили; на Android — фон заставки#ffffff
langязык текстовых полей вроде nameru

Заставку при запуске на Android Chrome собирает сам — из name, background_color и иконки; отдельной картинки для неё в манифесте нет, а theme_color красит панель над приложением. Если режим display браузеру не по силам, он спускается по цепочке fullscreen → standalone → minimal-ui → browser до первого режима, который умеет.

Иконки — самое капризное место. У каждой есть purpose. Обычная, any (так считается и без поля), рисуется как есть — с прозрачностью и без полей, как значок сайта. maskable — иконка, которую система обрежет своей маской: кругом, скруглённым квадратом, каплей. Всё важное на ней должно помещаться в круг радиусом 40% ширины с центром посередине, а внешние 10% по краю могут срезать. Отсюда правило: две разные картинки, а не одна на всё. Запись "purpose": "any maskable" web.dev прямо не советует: там, где иконка пойдёт как обычная, она выйдет с лишними полями и мельче, чем нужно.

Иконка maskable — не «иконка получше», а иконка с запасом полей под чужую маску. Обычную она не заменяет, а дополняет.

Как Chrome понял этот файл, показывает DevTools: панель Application, раздел Manifest, а в нём блок Installability — что мешает установке. Те же ответы можно получить у Chrome через протокол DevTools; мой сценарий так и делает, чтобы вывод можно было положить сюда текстом:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · check-manifest.mjs — мой сценарий: открывает адрес в Chrome 152 без окна и печатает ответы Page.getAppManifest и Page.getInstallabilityErrors — то, что панель Application → Manifest показывает таблицей
http://127.0.0.1:3341/
манифест: http://127.0.0.1:3341/manifest.webmanifest
start_url: http://127.0.0.1:3341/
scope: http://127.0.0.1:3341/
id: http://127.0.0.1:3341/
name: Доска задач
display: kStandalone
theme_color: rgba(37,99,235,1) | background_color: rgba(255,255,255,1)
иконок: 3
мешает установке: нет

Все адреса стали полными, цвета — числами, а display записан внутренним именем Chrome, kStandalone. Главная строка — последняя: установке ничего не мешает. Service worker в этом решении не участвует. В критериях Chrome его больше нет, и манифест на странице без воркера проходит так же — это стенд тоже показал. Воркер нужен доске не для установки, а чтобы поставленное приложение открывалось без сети.

Забегая вперёд, к Next.js в четвёртой части: там манифест можно не писать руками, а вернуть объектом из файла app/manifest.ts, и Next.js сам отдаст его по адресу — тем же приёмом, каким в той части собирают карту сайта из app/sitemap.ts. Поля те же, и правила, о которых дальше пойдёт речь, от этого не меняются.

Адреса в манифесте можно писать относительными — "./", "index.html", "../". Вопрос в том, от чего их отсчитывать. Кандидатов три: адрес страницы, на которой нашли манифест, адрес самого манифеста и корень сайта. Сначала посчитаем, что даёт каждый. Прогноз тут не так прост, как кажется: у new URL с базой свои правила, мы встречали их в первой статье главы.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Первая строка — ловушка сама по себе: от /tasks/17 адрес ./ ведёт в /tasks/, потому что последний сегмент без слэша считается именем файла и отбрасывается. А теперь главное: какую из этих баз возьмёт браузер? На стенде манифест с "start_url": "./" и "scope": "./" лежит в папке static, а ссылка на него стоит на странице /exp/relative.html:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · мой сценарий check-manifest.mjs, Chrome 152 без окна
http://127.0.0.1:3341/exp/relative.html
манифест: http://127.0.0.1:3341/static/manifest.webmanifest
start_url: http://127.0.0.1:3341/static/
scope: http://127.0.0.1:3341/static/
id: http://127.0.0.1:3341/static/
мешает установке: нет
shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, тот же сервер стенда; для контроля /exp/relative.html на нём же ответил 200
404

Chrome отсчитал от манифеста: и start_url, и scope стали /static/. Так записано в спецификации манифеста, и причина понятна: один манифест подключают десятки страниц сайта, и приложение не должно зависеть от того, на какой из них его нашли. Посмотрите и на строку «мешает установке: нет». Chrome готов поставить приложение, которое при запуске откроет /static/, а там у сервера 404. Проверки на это в критериях нет.

Относительные адреса в манифесте отсчитываются от адреса манифеста. Надёжнее всего писать их от корня — "/", "/app/": такой адрес не зависит ни от страницы, ни от папки, куда положили файл.

Второе, что нужно знать про эти поля: из-за одного неудачного значения браузер не отвергает манифест целиком. Он отбрасывает поле и берёт значение по умолчанию. Правила короткие, соберём их функцией. По умолчанию start_url — адрес страницы, а scope — папка start_url. start_url с чужого происхождения отбрасывается; scope, внутри которого не оказался start_url, — тоже. «Внутри» здесь — то же совпадение префикса, что и у области действия воркера. Предскажите четыре блока вывода.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Во втором блоке scope /app/ не накрывает start_url / — и отброшен именно scope, а не весь манифест: область стала папкой start_url, то есть всем сайтом. В третьем отброшен start_url, и приложение будет запускаться с той страницы, где его поставили, — здесь это /help/faq.html, а область — её папка. Проверим те же два случая на живом Chrome:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · мой сценарий check-manifest.mjs, Chrome 152 без окна; в outside.webmanifest "start_url": "/" и "scope": "/app/", в foreign.webmanifest "start_url": "https://other.example/"
http://127.0.0.1:3341/exp/outside.html
манифест: http://127.0.0.1:3341/exp/outside.webmanifest
предупреждение: property 'scope' ignored. Start url should be within scope of scope URL.
start_url: http://127.0.0.1:3341/
scope: http://127.0.0.1:3341/
id: http://127.0.0.1:3341/
мешает установке: нет
http://127.0.0.1:3341/exp/foreign.html
манифест: http://127.0.0.1:3341/exp/foreign.webmanifest
предупреждение: property 'start_url' ignored, should be same origin as document.
start_url: http://127.0.0.1:3341/exp/foreign.html
scope: http://127.0.0.1:3341/exp/
id: http://127.0.0.1:3341/exp/foreign.html
мешает установке: start-url-not-valid

Предупреждения Chrome — те же правила его словами, и оба говорят, что поле проигнорировано, а не что манифест сломан. Но в последней строке есть расхождение, и оно полезное. По спецификации манифест с отброшенным start_url остаётся годным, а у Chrome к установке своё требование: start_url должен быть задан и принят. Поэтому манифест, где scope не накрывает start_url, ставится, хотя область получилась не та, что задумана, а манифест с start_url на чужом сайте — нет: start-url-not-valid.

Совет «start_url вне scope ломает установку» неверен. Ломается не установка, а замысел: область молча становится шире, чем вы её написали.

Решение «можно ставить» у каждого браузера своё, и лучше всех описано решение Chrome — на web.dev. Сайт должен открываться по HTTPS (localhost, как и для воркера, считается безопасным, а HTTPS на своём сервере настроим в части VI). Приложение не должно быть уже установлено. Человек должен хотя бы раз нажать на странице и провести на ней не меньше 30 секунд — это эвристика вовлечённости, и считается она за всё время, а не за одно посещение. А в манифесте должны быть name или short_name, start_url, иконки 192 и 512 пикселей, display — одно из fullscreen, standalone, minimal-ui или window-controls-overlay, и не должно быть "prefer_related_applications": true — так манифест говорит «ставьте лучше приложение из магазина».

Соберём проверку функцией. Разбор адресов из прошлого раздела уже лежит на странице примера. Ошибки назовём так же, как их называет сам Chrome, чтобы сверка шла буква в букву, а то, что установке не мешает, но портит вид, пусть идёт советом. Одна оговорка до запуска: правила в функции взяты не из абзаца выше, а из опытов над Chrome 152, и где они с абзацем расходятся, станет видно по выводу. Предскажите, какие из пяти манифестов пройдут.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три манифеста из пяти не ставятся, и у двух причина неожиданная. «Только maskable» не проходит, хотя иконок две и обе нужного размера: иконки под чужую маску Chrome за обычные не считает, а без обычной показать приложение ему нечем. «Без иконки 512» проходит, хотя web.dev требует 512: сам Chrome проверяет только, что есть обычная иконка от 144 пикселей. А window-controls-overlay из списка web.dev в поле display не годится вовсе. Это значение для display_override — поля, где режимы окна перечисляют по убыванию желания и откуда браузер берёт первый, который умеет.

Откуда такие правила — вот прогон Chrome по десяти страницам. У каждой свой манифест:

СтраницаЧто в манифесте
/манифест доски
http://board.test:3341/манифест доски, но адрес не localhost: board.test — имя, которое я флагом Chrome направил на свою же машину
/exp/noname.htmlнет ни name, ни short_name
/exp/nostart.htmlнет start_url
/exp/browser.html"display": "browser"
/exp/wco.html"display": "window-controls-overlay"
/exp/override.html"display": "browser" и "display_override": ["standalone"]
/exp/maskonly.htmlдве иконки, 192 и 512, обе maskable
/exp/anymask.htmlдве иконки, 192 и 512, обе "purpose": "any maskable"
/exp/no512.htmlодна обычная иконка 192×192
shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · мой сценарий check-manifest.mjs с ключом --short: одна строка на страницу, только ответ Page.getInstallabilityErrors. Chrome 152 без окна, чистый профиль
/ → нет
http://board.test:3341/ → not-from-secure-origin
/exp/noname.html → manifest-missing-name-or-short-name
/exp/nostart.html → start-url-not-valid
/exp/browser.html → manifest-display-not-supported
/exp/wco.html → manifest-display-not-supported
/exp/override.html → нет
/exp/maskonly.html → manifest-missing-suitable-icon (minimum-icon-size-in-pixels: 144), no-acceptable-icon (minimum-icon-size-in-pixels: 144)
/exp/anymask.html → нет
/exp/no512.html → нет

На общих случаях модель и Chrome совпадают, и видно ещё три вещи. Первая: not-from-secure-origin у board.test — страница та же, сервер тот же, отличается только имя в адресе. На такой странице нет и воркера: navigator.serviceWorker там undefined, и строка регистрации в app.js на стенде упала с TypeError. Вторая: display_override проходит даже при "display": "browser", потому что браузер читает его первым. Третья: any maskable Chrome ставит, так что совет web.dev — про внешний вид, а не про установку. Для window-controls-overlay в подробном выводе Chrome пишет ещё и предупреждение «inapplicable 'display' value ignored.»: значение в этом поле он просто выбросил.

Часть критериев стенд не проверил. Главный из них — вовлечённость: в Chrome без окна событие установки beforeinstallprompt (о нём ниже) приходило сразу после загрузки, без нажатий и тридцати секунд, поэтому по такому стенду нельзя судить, как эвристика поведёт себя у вас. "prefer_related_applications": true Chrome 152 на компьютере установке не помешал, а на Android я его не проверял. И «приложение ещё не установлено» тоже осталось без проверки: поставить приложение в Chrome без окна стенд не может.

Проверку установки пишут по поведению браузера, а документацию используют как список того, что проверить. На стенде они разошлись в трёх местах: размер иконки, window-controls-overlay и prefer_related_applications.

Критерии выше — про Chrome и браузеры на его движке. Остальные ставят приложения по-своему, и разница не в мелочах: где-то сайт предлагает установку сам, где-то человек ищет пункт в меню, а где-то манифест вообще не обязателен. Первое различие видно прямо из песочницы. Предскажите три строки — и учтите, что ответ зависит от браузера, в котором вы читаете эту страницу.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В Chrome все три строки — true, в Firefox все три — false; по данным MDN о поддержке, Edge отвечает как Chrome, а Safari — как Firefox. События beforeinstallprompt и appinstalled нестандартные и есть только в Chromium — как и фоновая синхронизация из статьи «Без сети». Откройте эту страницу в другом браузере и запустите пример ещё раз: так быстрее всего убедиться, что кнопка «Установить» на сайте бывает не у всех.

ГдеКак ставятСвоя кнопка на сайтеPushФоновая синхронизация
Chrome и Edge на компьютерезначок установки в адресной строке или пункт в меню; сами предлагают, если сайт прошёл критерииесть: beforeinstallpromptесть, и во вкладкеесть
Chrome на Android, Samsung Internetпредложение браузера или пункт в меню; Chrome на устройствах с сервисами Google и Samsung Internet на телефонах Samsung ставят WebAPK — пакет Android, собранный из манифестаесть: beforeinstallpromptесть, и во вкладкеесть
Firefox на Androidярлык на домашнем экране со значком браузеранетесть, и во вкладкенет
Firefox на компьютерес версии 143 и только в Windows: сайт закрепляют на панели задач, и он открывается своим окном; в сборке из Microsoft Store на момент выхода этого не было, в macOS и Linux нетнетесть, и во вкладкенет
Safari на Macс Safari 17 в macOS Sonoma — «Файл → Добавить в Dock»; любой сайт, манифест не обязателен, открывается своим окномнетесть: в Safari с macOS Ventura и у приложения в Dockнет
Safari на iPhone и iPad«Поделиться → На экран Домой»; с iOS 16.4 — и из других браузеров; с iOS 26 любой сайт по умолчанию открывается как веб-приложение, даже без манифестанетс iOS 16.4, только у приложения на экране Домойнет

Самая большая перемена последнего года — в iOS 26 и iPadOS 26. Раньше сайт без манифеста (и без старого мета-тега Apple) открывался с экрана Домой как закладка — в Safari. Теперь в окне добавления на экран есть переключатель «открыть как веб-приложение», и включён он по умолчанию: своим окном открывается любой сайт. Манифест при этом не теряет смысла — если он есть, iOS берёт из него иконки и остальные поля. Но узнать, что его поставили, сайт на iPhone не может: ни beforeinstallprompt, ни appinstalled там нет.

Про push в этой таблице — только где он есть. Как устроена подписка и почему на iPhone без экрана Домой её не бывает, разберём в следующей статье. А выбирать по таблице между установкой из браузера и выпуском в магазин будем в пятой части, в статье «PWA или приложение из магазина»: здесь механизмы, там решение.

Свою кнопку «Установить» сайт может показать только в Chromium. Везде остальное — подсказка словами или ничего, и это нормальный ответ, а не недоработка.

Там, где событие есть, схема такая. Браузер решил, что сайт годится к установке, и прислал beforeinstallprompt. Сайт отменяет собственное предложение браузера через preventDefault(), прячет событие в переменную и показывает свою кнопку. По нажатию он зовёт у события prompt(), и браузер открывает своё окно установки; ответ человека приходит в userChoice — "accepted" или "dismissed". Вызвать prompt() у одного события можно один раз: после отказа придётся ждать, пока браузер пришлёт новое. Когда приложение уже установлено (на компьютере или как WebAPK на Android), событие больше не приходит. Вот app.js доски целиком:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна, первое открытие http://127.0.0.1:3341/ в чистом профиле
display-mode standalone: false
controller: нет
задач на экране: 3
beforeinstallprompt: платформы ["web"]

Событие пришло, поле platforms у него — ["web"]: ставится веб-приложение. Кнопка появилась. Нажмём её и посмотрим в журнал сервера:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · журнал того же прогона; строку «===» мой сценарий пишет в момент нажатия
=== нажали «Установить доску» ===
127.0.0.1 - - [13/Sep/2026 11:52:25] "GET /icons/icon-192.png HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 11:52:25] "GET /icons/icon-512.png HTTP/1.1" 200 -
127.0.0.1 - - [13/Sep/2026 11:52:25] "GET /icons/icon-maskable-512.png HTTP/1.1" 200 -

Окно установки, судя по журналу, скачало все три иконки. Самого окна в Chrome без экрана не видно, и ответить в нём некому, поэтому до userChoice этот стенд не дошёл. Зато он показал, чего делать нельзя. Соблазнительно не возиться с кнопкой и позвать prompt() прямо в обработчике события:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна, отдельная страница стенда с манифестом доски
beforeinstallprompt пришло
prompt() не сработал: NotAllowedError: Failed to execute 'prompt' on 'BeforeInstallPromptEvent': The prompt() method must be called with a user gesture

NotAllowedError: окно установки открывается только в ответ на действие человека — нажатие или касание. Та же защита стоит у всплывающих окон: сайт не может навязать установку, пока человек на него только смотрит.

Вторая половина кнопки — знать, когда её не показывать: если доску уже запустили с экрана, предлагать установку глупо. Открыто ли приложение своим окном, говорит медиазапрос display-mode. Это тот же matchMedia, что в статье про вычисленные стили, только спрашивает он не про ширину, а про режим окна. Песочница — обычная вкладка; предскажите четыре строки.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Во вкладке верно только browser. В окне установленной доски true ответит запрос с её режимом — у нас standalone. Но у этой проверки две ловушки. Первая — в самом Chrome: видео или игра на весь экран через Fullscreen API тоже включают display-mode: fullscreen. На стенде после document.documentElement.requestFullscreen() запрос fullscreen стал true, а browser — false. Вторая — в Safari на iPhone: по данным MDN о поддержке и открытому с 2023 года отчёту WebKit 264218, приложение с "display": "standalone", запущенное с экрана Домой, отвечает true на fullscreen и false на standalone. На iPhone с iOS 26 я это не проверял, поэтому проверку пишут с запасом на оба случая.

Соберём решение «что показать» функцией. Предскажите семь строк, особенно третью и пятую.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Третья строка — Chrome с видео на весь экран: fullscreen там верен, но есть и fullscreenElement, значит, это не запуск с экрана, и кнопка остаётся. Пятая — iPhone с экрана Домой: standalone молчит, зато fullscreen без полноэкранного элемента выдаёт приложение. Как узнать, что перед вами iPhone, модель не решает, это флаг. В настоящем коде это обычно navigator.userAgent, и способ ненадёжный: iPad по умолчанию представляется компьютером Mac. Поэтому подсказку «Поделиться → На экран Домой» делают ненавязчивой и с кнопкой «закрыть».

«Открыто как приложение» — это не один медиазапрос, а standalone, minimal-ui или fullscreen без полноэкранного элемента. Проверка меньшего ломается на iPhone или на первом же видео.

Последняя ловушка стоит на стыке манифеста и воркера. Хочется знать, сколько людей открывают доску с иконки, а не из вкладки, и в start_url дописывают метку: "/?source=homescreen". Сервер увидит её в журнале, аналитика — в адресе, а id у доски задан явно, так что для браузера это то же приложение. Оболочка в воркере прежняя, из статьи «Без сети»: главная /, запасная страница, код и стили. Ветка навигации тоже прежняя и уже лежит на странице примера. Предскажите три строки.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Иконка без сети открыла запасную страницу, хотя доска лежит в кеше и по своему адресу без сети отдаётся как ни в чём не бывало. Ключ кеша — адрес целиком, вместе со строкой запроса: / и /?source=homescreen для него разные записи, это мы проверяли на живом Cache API в статье про стратегии кеша. Ветка навигации честно поискала ровно этот адрес, не нашла и отдала запасную.

Проверим на доске. Манифест с меткой, воркер из статьи «Без сети», два открытия с сетью, потом сервер останавливается. Запустить приложение с иконки в Chrome без окна нельзя, поэтому start_url открывается в новой вкладке: для воркера это та же навигация.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · start-offline.mjs — мой сценарий, Chrome 152 без окна, чистый профиль; v5q — вариант доски с "start_url": "/?source=homescreen". Строки двух загрузок с сетью убраны. «===», «---», «start_url…», «в кеше…» и «заголовок | h1» печатает сценарий, остальное — сама доска
start_url, как его понял Chrome: http://127.0.0.1:3341/?source=homescreen
в кеше board-v5: /offline.html, /assets/app.7e4d2f90.js, /assets/board.5c7d0a11.css, /, /api/tasks.json
--- сервер остановлен ---
=== запуск с экрана без сети: http://127.0.0.1:3341/?source=homescreen ===
заголовок | h1: "Доска без сети" | "Эта страница пока недоступна"
=== без сети, просто адрес доски: http://127.0.0.1:3341/ ===
display-mode standalone: false
controller: есть
задач на экране: 3
заголовок | h1: "Доска задач" | "Доска задач"

Chrome понял start_url ровно так, как написано. В кеше / есть, а /?source=homescreen нет, и вместо доски открылась «Доска без сети». В жизни это выглядит так: человек поставил доску на экран, вечером закрыл, утром в метро нажал на иконку — и увидел запасную страницу. Стоит один раз открыть иконку с сетью, и всё заработает: ветка навигации положит в кеш и этот адрес. Но после выхода новой версии activate удалит старый кеш, и история повторится.

Лечится одной строкой: start_url кладут в оболочку ровно в том виде, в каком он записан в манифесте.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · тот же сценарий на варианте доски v5q-fixed, где в sw.js изменилось только это; показаны строка «в кеше» и запуск с иконки без сети
в кеше board-v5: /?source=homescreen, /offline.html, /assets/app.7e4d2f90.js, /assets/board.5c7d0a11.css, /, /api/tasks.json
--- сервер остановлен ---
=== запуск с экрана без сети: http://127.0.0.1:3341/?source=homescreen ===
display-mode standalone: false
controller: есть
задач на экране: 3
заголовок | h1: "Доска задач" | "Доска задач"

Есть и второй путь — искать навигацию в кеше без строки запроса: cache.match(request, { ignoreSearch: true }). Он короче, но меняет правило для всех страниц сразу: будь у доски поиск, /search?q=молоко без сети открыл бы сохранённую страницу поиска по совсем другому слову. Для доски, у которой строка запроса нужна только метке, START в оболочке честнее.

Всё, что открывается с иконки, должно лежать в оболочке буква в букву — вместе со строкой запроса.

Доска стала приложением: ставится на экран там, где платформа это умеет, знает, когда запущена с иконки, и с иконки же открывается без сети. Осталась последняя способность настоящего приложения — сообщить о чём-то, когда доска закрыта. Функция «открыто как приложение» уезжает в следующую статью готовой: на iPhone push получает только приложение, запущенное с экрана Домой.

  • Манифест — JSON-файл, на который страница ссылается через <link rel="manifest">; читает его браузер. Главные поля: name и short_name, start_url, scope, id, display, icons, theme_color, background_color. Заставку на Android Chrome собирает из name, background_color и иконки.
  • Относительные start_url и scope разрешаются от адреса манифеста, а не страницы. Неудачное поле отбрасывается, а не ломает манифест: scope, не накрывший start_url, откатывается к папке start_url, а start_url с чужого сайта — к адресу страницы, но такой манифест Chrome к установке уже не пустит.
  • Chrome 152 ставит приложение с HTTPS или localhost, если в манифесте есть name или short_name, принятый start_url, режим fullscreen, standalone или minimal-ui (можно через display_override) и обычная иконка от 144 пикселей; service worker не нужен. Иконки делают раздельно: обычные 192 и 512 плюс отдельная maskable.
  • Своя кнопка «Установить» бывает только в Chromium: событие beforeinstallprompt прячут, а prompt() зовут по нажатию и один раз — без жеста он отклоняется с NotAllowedError. На iPhone ставят через «Поделиться → На экран Домой», с iOS 26 так своим окном открывается любой сайт; в Safari на Mac — «Файл → Добавить в Dock»; Firefox на компьютере ставит сайты с версии 143 и только в Windows.
  • Открыто ли как приложение, отвечает display-mode: standalone или minimal-ui, либо fullscreen без полноэкранного элемента. Так проверка не спутает запуск с видео на весь экран и учтёт iPhone, где приложение, по данным MDN, отвечает на fullscreen.
  • Метка в start_url делает его другим ключом кеша, и без сети запуск с иконки откроет запасную страницу. start_url кладут в оболочку воркера ровно в том виде, в каком он записан в манифесте.

1/4Манифест лежит по адресу /static/manifest.webmanifest, в нём "start_url": "./". Ссылка на него стоит на странице /tasks/17. Какой адрес откроется при запуске с иконки?

2/4В манифесте "start_url": "/" и "scope": "/app/". Что сделает Chrome?

3/4Обе иконки в манифесте, 192×192 и 512×512, помечены "purpose": "maskable". Поставит ли Chrome такое приложение?

4/4Сайт вызвал event.prompt() прямо в обработчике beforeinstallprompt, не дожидаясь нажатия. Что будет?

Какая иконка считается обычной

Проверка из статьи уже на странице, а рядом — её упрощённая версия для иконок, и упрощена она неправильно: годной считает любую иконку от 144 пикселей, какой бы ни был её purpose. Сейчас «только maskable» печатает «годится», а Chrome 152 такой манифест ставить отказывается.

Исправьте iconErrors так, чтобы в расчёт шли только иконки, у которых среди назначений есть any. Напомним, что иконка без поля purpose — обычная. Функции purposes и sides лежат на странице.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отбирать надо по назначению — до проверки размера:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Первая строка становится только maskable: manifest-missing-suitable-icon, остальные не меняются. Третья строка — не ошибка решения: any maskable Chrome 152 на стенде принял, такая иконка портит только вид. Четвёртую подтверждает тот же стенд: без sizes Chrome не знает размера и иконку не берёт.

Короткое сравнение icon.purpose === "any" здесь не годится дважды: у обычной иконки поля purpose часто нет вовсе, и вторая строка сломалась бы, а "any maskable" — два назначения в одной строке, поэтому purposes и разбивает её по пробелу.

Окно установки — по нажатию

Модель страницы и события установки уже на странице. page.click вызывает обработчик так, будто человек нажал, а prompt() у события, как и в Chrome 152 на стенде, отклоняется с NotAllowedError, если нажатия не было.

Сейчас setupInstall зовёт prompt() сразу в обработчике события и получает отказ, а кнопка ничего не делает. Перенесите вызов в кнопку: событие спрячьте, по первому нажатию откройте окно и напечатайте выбор. Второе нажатие не должно звать prompt() повторно — у события он одноразовый.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обработчик события только прячет его, а всё остальное делает кнопка:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: beforeinstallprompt пришло, нажали «Установить доску», строка модели про окно установки, выбор: accepted и второе нажали «Установить доску» — уже без окна. Событие обнуляется до await: иначе быстрое двойное нажатие успело бы позвать prompt() дважды. И prompt() стоит до первого await в обработчике: разрешение, которое даёт нажатие, живёт недолго, и долгая работа перед вызовом может его растратить. На настоящей доске после нажатия кнопку просто прячут, как в app.js из статьи.

start_url — в оболочку

Оболочку воркера собирает функция shellFor, и сейчас она не знает про манифест: в список попадают только главная и запасная страница. Для манифеста с "start_url": "/" этого хватает, а с меткой в адресе запуск с иконки без сети кончается запасной страницей.

Допишите shellFor так, чтобы в оболочку попадал и start_url из манифеста — полным адресом, разрешённым так же, как его разрешает браузер. Если он совпадает с уже включённым адресом, дубль не нужен. Ветка навигации, сеть и кеш из статьи «Без сети» лежат на странице.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Адрес разрешается от адреса манифеста, как в разделе про start_url, и добавляется, только если его ещё нет:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Первая строка не меняется — / уже в оболочке. Вторая становится ./?source=homescreen → в оболочке 3 | иконка без сети: / v1 | кеш. Сравнивать нужно полные адреса: строка "./?source=homescreen" и адрес https://board.example/?source=homescreen — одно и то же для браузера, но не для includes. В настоящем sw.js манифеста под рукой нет, поэтому START вписывают руками, как в статье, или его подставляет сборка; shellFor — модель такого шага сборки.