Bare React Native
Otapush работает и с bare React Native — проектами без app.json и managed workflow — точно так же, как с Expo. Нативный модуль expo-updates нормально живёт в bare RN: ему нужны пакет expo (expo-modules core) плюс нативная конфигурация, которую otapush init запишет за вас. Серверная часть идентична: bare-приложение говорит по тому же Expo Updates Protocol v1, описанному в справочнике протокола.
Что нужно bare-проекту RN, прежде чем заработает Otapush?
Два npm-пакета — expo (expo-modules core) и expo-updates — и нативная обвязка для них:
npx install-expo-modules@latest
npm install expo-updates # или: yarn add / pnpm add / bun add
cd ios && pod install
install-expo-modules добавляет пакет expo и подключает автолинковку expo-modules в android/ и ios/. Если какой-то из пакетов отсутствует, otapush init напечатает точные команды установки — подобрав пакетный менеджер по lockfile (bun.lock/bun.lockb, yarn.lock, pnpm-lock.yaml, иначе npm) — и всё равно продолжит: нативные файлы будут пропатчены, но приложение не соберётся, пока пакеты не установлены.
Как otapush init понимает, что проект bare?
Он смотрит на корень проекта: app.json с полем expo — managed flow; нет app.json, но в package.json есть зависимость react-native — bare. Для bare-проекта init:
- сохраняет сертификат подписи кода в
./certs/certificate.pem(публичный материал — коммитьте), - патчит
android/app/src/main/AndroidManifest.xml, - создаёт или обновляет
ios/<ProjectName>/Expo.plist— либоios/<ProjectName>/Supporting/Expo.plist, если тот уже существует; имя проекта берётся из каталогаios/*.xcodeproj, - записывает
otapush.config.jsonсprojectType: "bare".
Патчинг идемпотентен: повторный запуск init заменяет значения существующих ключей, а не дублирует их.
Что именно патчится?
Android: android/app/src/main/AndroidManifest.xml
Внутри <application> вставляются meta-data-записи (имена ключей взяты из UpdatesConfiguration.kt в исходниках expo-updates):
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://your-server/api/updates/<slug>/manifest"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="1.0.0"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.CODE_SIGNING_CERTIFICATE" android:value="-----BEGIN CERTIFICATE----- ..."/>
<meta-data android:name="expo.modules.updates.CODE_SIGNING_METADATA" android:value="{"alg":"rsa-v1_5-sha256","keyid":"main"}"/>
Существующий meta-data с тем же android:name заменяется целиком. Обратите внимание: EXPO_UPDATE_URL указывает на эндпоинт манифеста без канала — сервер обслуживает prod, если URL не содержит ?channel=<name>.
iOS: ios/<ProjectName>/Expo.plist
Ключи верхнего уровня (имена из UpdatesConfig.swift):
<key>EXUpdatesEnabled</key><true/>
<key>EXUpdatesURL</key><string>https://your-server/api/updates/<slug>/manifest</string>
<key>EXUpdatesRuntimeVersion</key><string>1.0.0</string>
<key>EXUpdatesCheckOnLaunch</key><string>ALWAYS</string>
<key>EXUpdatesCodeSigningCertificate</key><string>-----BEGIN CERTIFICATE----- ...</string>
<key>EXUpdatesCodeSigningMetadata</key>
<dict>
<key>alg</key><string>rsa-v1_5-sha256</string>
<key>keyid</key><string>main</string>
</dict>
Если файл существует, шесть «подопечных» ключей удаляются и вставляются заново в конец словаря; если нет — вокруг них создаётся минимальный plist.
Expo.plistдолжен входить в Xcode target —expo-updatesчитает его из основного бандла. Еслиinitсоздал файл, которого в проекте не было, добавьте его в target в Xcode.
Что делать, если файла нет?
Вот ошибки, которые init выдаёт в bare-проекте, дословно, и что с ними делать:
- "AndroidManifest.xml not found at android/app/src/main/AndroidManifest.xml" — вы не в корне проекта или проект использует нестандартную структуру. Запустите
initиз корня; для нестандартных путей (бывает в монорепозиториях) впишите meta-data вручную по образцу выше. - "No
tag found in …" — манифест повреждён или сильно кастомизирован; добавьте meta-data вручную внутрь<application>. - "No ios/ directory found" или **"No .xcodeproj found under ios/"* —
initне может определить target приложения, рядом с которым кластьExpo.plist. Запустите из корня проекта с iOS-частью либо создайтеExpo.plistсами по образцу и добавьте в target. - "… is not a valid plist (no closing ). — существующий
Expo.plistповреждён; почините или удалите его и запуститеinitснова. - "Warning: missing required package(s): expo, expo-updates" — не фатально: патчинг завершится, но перед сборкой установите пакеты (команды под ваш lockfile напечатаны).
Как собрать, опубликовать и проверить?
Соберите release-бинарник как всегда — Gradle, Xcode, fastlane. OTA работает только для release-сборок; dev-сборки берут JavaScript из Metro и сервер не проверяют. Смена версии рантайма в нативном конфиге (EXPO_RUNTIME_VERSION / EXUpdatesRuntimeVersion) — это нативное изменение: правьте эти файлы и пересобирайте.
Как и managed-приложение, bare-приложение должно отправлять стабильный device id, иначе сервер ответит 400 deviceId is required. JS API тот же — Updates.setExtraParamAsync("deviceid", id) один раз при запуске с постоянным идентификатором; сниппет и предупреждение про строчный ключ — в Настройке Expo.
Публикация не отличается от managed flow — из корня проекта:
otapush publish --channel prod --platform all --message "Fix typo"
Для bare-проектов вместо expo export CLI выполняет npx react-native bundle --platform <p> --dev false --entry-file <index.js|index.ts> --bundle-output dist-ota/bare-<p>/bundle.js --assets-dest …, рекурсивно собирает созданные ассеты (react-native раскладывает их по вложенным папкам: assets/node_modules/..., drawable-*) и загружает всё по тому же контракту. Проверка — те же два запуска, что и в Быстром старте: первый запуск скачивает, второй выполняет.
Чем bare отличается от managed flow?
- Поверхность конфигурации. Вместо
app.json+expo prebuild— прямые нативные файлы; только и всего, в возможностях сервера разницы нет. - Только стандартные структуры.
initпатчит стандартную структуру проекта RN; экзотику придётся настраивать вручную по образцам выше. - Смена версии рантайма — ручная правка нативного конфига плюс пересборка — поля в
app.json, которое можно поменять, здесь нет.
Всё остальное — каналы, публикация, promote, rollback, подпись кода, требование device id — идентично.
Вопросы и ответы
Сервер как-то отличает bare-приложение от Expo-приложения?
Нет. Протоколу всё равно, как собран клиент; bare-приложение получает те же манифесты, директивы и подписи. Разница только в том, где на клиенте лежит конфигурация.
Как отправить device id из bare-приложения?
В точности как в managed — JavaScript API expo-updates одинаков: await Updates.setExtraParamAsync("deviceid", id) со стабильным id из expo-application. Сниппет и объяснение, почему ключ должен быть строчным, — в Настройке Expo.
Умеет ли otapush init патчить монорепозиторий с нестандартными путями?
Нет. Он патчит android/app/src/main/AndroidManifest.xml и ios/<ProjectName>/Expo.plist, а в остальных случаях завершается ошибкой — намеренно, вместо того чтобы угадывать. Запускайте его в checkout со стандартной структурой или примените образцы из раздела «Что именно патчится?» вручную.
Какой канал проверяет bare-сборка?
Тот, что в её URL, как и у managed-сборок: «голый» URL манифеста означает prod. Добавьте ?channel=<name> в EXPO_UPDATE_URL / EXUpdatesURL для сборок, которые должны следовать за другим каналом.