Push — это рычаг возврата пользователя, и в web-first мобильном приложении он доступен двумя разными путями. Один — web push через стандартные браузерные API; второй — нативный push через WebView-обёртку. Они не взаимозаменяемы: у каждого свой механизм доставки, свои требования к разрешениям и свои ограничения на платформах. Продукт-инженеру важно понимать, какой путь когда работает, чтобы не обещать уведомления там, где их физически не будет.
Web push против нативного push
Web push строится на трёх стандартах: Push API (подписка на сообщения), Notification API (показ) и service worker (приём сообщения, когда вкладка закрыта). Подписка получает endpoint у push-сервиса браузера, бэкенд шлёт на него зашифрованное сообщение, service worker будит и показывает уведомление. Этот путь работает в PWA и в обычном браузере на desktop и Android — без какой-либо нативной обёртки.
Нативный push идёт мимо браузера: приложение в обёртке регистрируется в платформенной службе доставки, получает device token и отдаёт его бэкенду. Дальше сервер шлёт на платформенный шлюз, а тот доставляет уведомление в систему. Разница принципиальная: web push не требует магазина приложений и работает из коробки, нативный push требует упаковки и настройки на стороне платформы, но даёт полный охват — включая надёжный iOS.
FCM и APNs
Нативная доставка — это две разные службы. Android использует FCM (Firebase Cloud Messaging): бэкенд шлёт сообщение в FCM, тот доставляет его на устройство. iOS использует APNs (Apple Push Notification service): доставка идёт через шлюз Apple, и без него push на iPhone не приходит.
На практике FCM выступает единым шлюзом и для iOS тоже: APNs-ключ загружается в Firebase, бэкенд работает только с FCM, а FCM сам ретранслирует в APNs. Это убирает из бэкенда вторую интеграцию. Настройка платформенная: Android требует google-services.json в проекте, iOS — платного аккаунта Apple Developer, APNs-ключа (.p8) и включённой capability «Push Notifications» в Xcode. Эта часть всегда нативная, веб её не заменяет.
Push через Capacitor
В web-first стеке нативный push подключается плагином @capacitor/push-notifications. Он даёт единый API поверх FCM и APNs: запрос разрешения, регистрация, получение токена и обработка событий.
npm install @capacitor/push-notifications
npx cap sync
import { PushNotifications } from "@capacitor/push-notifications";
export async function registerPush(onToken: (token: string) => void) {
let status = await PushNotifications.checkPermissions();
if (status.receive === "prompt") {
status = await PushNotifications.requestPermissions();
}
if (status.receive !== "granted") {
throw new Error("push permission denied");
}
await PushNotifications.addListener("registration", (token) => {
onToken(token.value);
});
await PushNotifications.addListener("registrationError", (err) => {
console.error("push registration failed", err.error);
});
await PushNotifications.register();
}
register() запускает регистрацию в FCM или APNs и сам не показывает диалог разрешений — поэтому requestPermissions() вызывают первым. Результат приходит асинхронно в слушатель события registration (или registrationError), а не возвращается из вызова.
Разрешения и токены
Разрешение и токен — это два разных шага, и порядок важен. Сначала checkPermissions() возвращает текущий статус — объект PermissionStatus с полем receive типа PermissionState (prompt, prompt-with-rationale, granted, denied). Если состояние prompt (на Android возможно и prompt-with-rationale), вызывают requestPermissions() — он показывает системный диалог и возвращает такой же PermissionStatus. Только при granted имеет смысл register() — иначе токена не будет.
Device token приходит в слушателе registration и его нужно отправить на бэкенд, привязав к пользователю: именно по этому токену сервер потом адресует push. Токен не вечен — платформа может его сменить (переустановка, восстановление, ротация), поэтому событие registration обрабатывают при каждом запуске и обновляют токен на сервере, а не сохраняют один раз.
await PushNotifications.addListener("pushNotificationReceived", (notification) => {
console.log("received", notification.title, notification.body);
});
await PushNotifications.addListener("pushNotificationActionPerformed", (action) => {
navigateTo(action.notification.data?.route);
});
Событие pushNotificationReceived срабатывает, когда push приходит при открытом приложении; pushNotificationActionPerformed — когда пользователь тапнул по уведомлению, и через data удобно передавать маршрут для глубокой ссылки. Полезная нагрузка push — это данные, и работа с устройством здесь смыкается с нативными API обёртки.
Ограничения iOS
iOS — главная причина, по которой выбор пути нетривиален. Web push на iOS существует с версии 16.4, но только в установленном PWA: сайт нужно добавить на домашний экран через «Поделиться → На экран Домой», и лишь тогда Push API и service worker становятся доступны. В обычной вкладке Safari web push на iPhone не работает вовсе. Плюс системный диалог разрешения должен вызываться строго в ответ на жест пользователя — тап по кнопке, не автоматически при загрузке.
Это и есть развилка. Если охват iOS критичен и нельзя рассчитывать, что пользователь установит PWA, надёжный путь один — нативный push через APNs в обёртке. Web push на iOS остаётся опцией для уже установленного PWA, но как единственный канал на iPhone он ненадёжен. На Android и desktop таких ограничений нет — там web push полноценен. Этот разрыв между платформами — частный случай общего вопроса где границы веба в мобильной разработке.
Где это в UCP
Push-уведомления — это место, где web-first стратегия упирается в платформенные ограничения, и решение принимается осознанно, а не по умолчанию. Продукт-инженер выбирает канал по охвату и стоимости: web push там, где он работает (Android, desktop, установленный PWA на iOS), нативный push через FCM и APNs — там, где нужен гарантированный iOS. Это та же дисциплина границ, что и в решении нативное против веба: не «всё нативно» и не «всё в браузере», а инженерный выбор пути под конкретное требование.