Настройка Expo
Как подключить проект Expo (managed или prebuild) к вашему серверу Otapush и собрать release-бинарники локально, без EAS. Для проектов без app.json читайте вместо этого Bare React Native — нативная конфигурация там живёт в других файлах.
Что именно меняет otapush init в моём проекте?
Три файла, и ничего больше:
app.json— в блокеexpo.updatesпоявляютсяurl,enabled,codeSigningCertificateиcodeSigningMetadata; существующий блокupdatesдополняется, а не заменяется.expo.runtimeVersionзаписывается только при отсутствии ключа — заданное вручную значение никогда не перезаписывается. Остальные поля (ios,android, plugins, splash) не трогаются.certs/certificate.pem— публичный самоподписанный сертификат X.509 приложения, при каждом запуске запрашивается с сервера. Это публичный материал; коммитьте его.otapush.config.json— связка этого каталога с приложением: URL сервера, id приложения, slug, версия рантайма, тип проекта.
Чего init сознательно не делает: не устанавливает npm-пакеты, не пишет checkAutomatically и fallbackToCacheTimeout (действуют значения expo-updates по умолчанию) и не правит нативные файлы — в managed-проекте они генерируются expo prebuild из app.json.
Если в проекте app.config.js / app.config.ts вместо app.json, автоматический патчинг не поддерживается: init напечатает точный JSON для объекта expo и всё равно запишет сертификат и конфиг.
Как должен выглядеть app.json при ручной настройке?
{
"expo": {
"updates": {
"url": "https://ota.example.com/api/updates/my-app/manifest",
"enabled": true,
"checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 0,
"codeSigningCertificate": "./certs/certificate.pem",
"codeSigningMetadata": { "keyid": "main", "alg": "rsa-v1_5-sha256" }
},
"runtimeVersion": "1.0.0",
"ios": { "bundleIdentifier": "com.example.myapp" },
"android": { "package": "com.example.myapp" }
}
}
По полям:
updates.url— эндпоинт манифеста Otapush. Стандартный клиентexpo-updatesговорит на протоколе из коробки; Otapush SDK не существует. Без параметра?channel=сервер обслуживает каналprod.runtimeVersion— врата совместимости. Сервер отдаёт обновление только при точном совпадении версии. Увеличивайте при каждом нативном изменении (новая нативная зависимость, обновление SDK). Работает и объект политики вида{ "policy": "appVersion" }: клиент разрешает его в обычную строку до отправки, и сервер сопоставляет именно строку.checkAutomatically: "ON_LOAD"— клиент проверяет обновления при каждом запуске;fallbackToCacheTimeout: 0немедленно запускает кешированный бандл и качает обновление в фоне. Оба — значенияexpo-updatesпо умолчанию, и оба рекомендуются.codeSigningCertificate/codeSigningMetadata— заставляют клиента проверять подписи манифестов перед применением обновления.keyidдолжен совпадать с тем, которым подписывает сервер, — этоmain.
Как приложение представляется серверу?
Каждый запрос обновления требует стабильного device id — на нём строятся аналитика и учёт MAU, а без него сервер отвечает 400 deviceId is required (подробности в протоколе). В expo-updates SDK 52 нет API для смены URL обновлений или добавления заголовков в рантайме, но можно прикрепить extra-параметры, которые нативный клиент отправляет с каждым запросом манифеста и ассетов:
npx expo install expo-application
import * as Application from "expo-application";
import * as Updates from "expo-updates";
import { Platform } from "react-native";
async function registerDeviceId() {
const deviceId =
Platform.OS === "android"
? Application.getAndroidId() // SSAID: стабилен между запусками
: await Application.getIosIdForVendorAsync(); // IDFV: стабилен между запусками
if (deviceId && Updates.isEnabled) {
await Updates.setExtraParamAsync("deviceid", deviceId);
}
}
Оба идентификатора стабильны между запусками — это жёсткое требование, ведь случайный id на каждый запуск раздувает ваш MAU. Параметр сохраняется нативно, поэтому достаточно задать его один раз (задавать при каждом запуске тоже не вредно).
Ключ должен быть строчным (
deviceid). Extra-параметры передаются как structured-field dictionary по RFC 8941, а её ключи не допускают заглавных букв. На iOS сериализатор падает при сборке запроса, и весь заголовок молча отбрасывается — из приложения всё выглядит нормально, а на сервер запрос приходит вовсе без идентификатора. Otapush читает id без учёта регистра, но клиент способен отправить только строчный.
Полный рабочий пример — examples/expo-demo/App.tsx в репозитории Otapush; он также использует checkAutomatically: "ON_ERROR_RECOVERY" с ручными вызовами Updates.checkForUpdateAsync(), что обходит один безобидный краевой случай ниже.
Как собрать release-бинарник без EAS?
npx expo prebuild --clean
генерирует android/ и ios/ с вшитой конфигурацией обновлений (meta-data в манифесте / Expo.plist). Перезапускайте его при каждом изменении блока updates. Затем:
Android
cd android
./gradlew assembleRelease
Установка: ./gradlew installRelease. Подписывайте APK/AAB своим keystore как обычно (signingConfigs в android/app/build.gradle) — в подписи для Play Store ничего не меняется.
iOS
xcodebuild -workspace ios/MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath build/MyApp.xcarchive \
archive
Или откройте ios/MyApp.xcworkspace в Xcode, выберите конфигурацию Release и сделайте archive. Понадобятся собственный сертификат подписи Apple Developer и provisioning profile — как в любой сборке без EAS.
Симуляторы не прогоняют путь скачивания OTA так, как устройства; проверяйте обновления на реальном устройстве или на release-сборке в Android-эмуляторе.
Как проверить всю цепочку?
- Установите release-сборку и запустите один раз — она работает на вшитом бандле.
- Измените JavaScript, затем
otapush publish --channel prod --platform ios. - Запустите приложение, закройте, запустите снова: первый запуск скачивает в фоне, второй выполняет новый бандл.
- Проверьте вкладку Обзор портала: появились один check, один download и один install. Если check растёт, а install — нет, обновления скачиваются, но не запускаются — см. ниже.
Что пошло не так?
Ниже — сообщения, которые otapush init и сервер действительно выдают, с решением для каждого:
- "Not logged in. Run
otapush login --server <url>first." —initпривязывает проект к вашему аккаунту, поэтому перед списком приложений нужна сессия портала. - "Warning: no app.json / app.config.js found in the current directory — is this an Expo project root?" — вы запустили
initвне проекта. Перейдите в каталог сapp.jsonи запустите снова. - "app.config.js detected — automatic patching is only supported for app.json." — это не ошибка: CLI напечатал JSON-блок для вставки в объект
expoвашего конфига. Сертификат иotapush.config.jsonпри этом записаны. - "App "…" has no code-signing certificate on the server. Re-create the app." — запись приложения создана до появления поддержки подписи. Создайте новое приложение в портале и привяжитесь к нему.
- Ошибки подписи на устройстве после повторного
init— в бинарнике устаревший сертификат.initскачал свежий; вшейте его черезnpx expo prebuild --cleanи пересоберите. Сертификат компилируется в бинарник, а не скачивается в рантайме. 400 deviceId is requiredпри самом первом запуске после установки — приcheckAutomatically: "ON_LOAD"нативный клиент может проверить обновления раньше, чем ваш JavaScript зарегистрирует device id. Безвредно (следующая проверка успешна); полностью обходится черезON_ERROR_RECOVERYс ручными проверками, как в демо.- Обновление не приходит — пройдите чек-лист в Быстром старте: совпадение версии рантайма, канал (
prod, если URL не говорит иное), release-сборка, два запуска. - Скачивание падает на реальном iPhone, но работает в симуляторе — release-сборки подчиняются App Transport Security: URL обновлений должен быть HTTPS. Android с API 28 по той же причине блокирует обычный HTTP.
Вопросы и ответы
Нужны ли Otapush аккаунт Expo или EAS?
Нет. expo-updates поставляется вместе с Expo SDK и работает с любым совместимым сервером; сборка делается через expo prebuild плюс Gradle/Xcode на вашей машине. EAS Build, EAS Submit и EAS Update обходятся целиком.
Какой checkAutomatically выбрать?
ON_LOAD (по умолчанию) проверяет при каждом запуске — этого хочет большинство приложений. Берите ON_ERROR_RECOVERY с ручными Updates.checkForUpdateAsync(), только если хотите гарантировать, что первый запуск после установки не выполнит проверку до регистрации device id вашим JS, — как делает демо-приложение.
Работает ли runtime version вида { "policy": "appVersion" }?
Да — политику клиент разрешает в обычную строку версии (поле version приложения), и именно эта строка летает в каждом запросе и сопоставляется сервером дословно. Чего сервер не делает никогда, так это сопоставления по диапазону или «ближайшей версии»; см. Протокол.
Как сменить ключ подписи кода?
На месте — никак. Клиенты вшивают сертификат при сборке, поэтому новый ключ означает новый бинарник: создайте новое приложение (вместе с ним генерируется новая пара ключей), заново выполните otapush init, пересоберите и отправьте через стор. Эндпоинта ротации на сервере нет.