Настройка 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-эмуляторе.

Как проверить всю цепочку?

  1. Установите release-сборку и запустите один раз — она работает на вшитом бандле.
  2. Измените JavaScript, затем otapush publish --channel prod --platform ios.
  3. Запустите приложение, закройте, запустите снова: первый запуск скачивает в фоне, второй выполняет новый бандл.
  4. Проверьте вкладку Обзор портала: появились один 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, пересоберите и отправьте через стор. Эндпоинта ротации на сервере нет.