Обновление по воздуху — это HTTP-ответ

обновления≈ 14 минут

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

В прошлой главе «Подход» ушёл в магазины, и следующая правка — таймер отдыха не на 60 секунд, а на 90 — упирается в новую сборку и новую проверку. Обновление по воздуху обходит магазин: новый JS долетает до приложения, которое уже стоит на телефоне. Звучит как магия, а устроено как обычный HTTP. При запуске телефон делает один GET, получает в ответ JSON со ссылками, качает файлы по этим ссылкам и сверяет их отпечатки. Разберём этот ответ руками на миниатюре сервера: от тела multipart/mixed до решения, какие файлы качать. Каждый шаг сверим с исходниками expo-updates 57.

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

Обновления в приложении автора включает секция updates в app.config.js. Сам файл мы разбирали в статье «Что Expo делает за вас», а вот эта его часть:

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

Этаж здесь — Expo. Всё, что дальше, делает пакет expo-updates, и не его JS, а нативная часть на Kotlin и Swift, ещё до того, как запустится бандл. checkAutomatically: "ON_LOAD" значит «проверять при каждом запуске», fallbackToCacheTimeout: 8000 — «ждать проверку и загрузку не дольше восьми секунд». Успел телефон за это время — приложение стартует уже с новым кодом. Не успел — стартует с последним, что лежит на телефоне, а скачанное применится при следующем запуске. Так поле описано в типах @expo/config-types 57. Отсюда первая примета: вы выкатили обновление, открыли приложение на медленной сети и видите старую версию. Это не сбой, просто обновление применится со второго запуска.

Сама проверка — обычный запрос на адрес из updates.url. Ниже он выписан по FileDownloader.kt из expo-updates 57.0.17, заголовки — в том порядке, в каком их ставит Android, значения — для сборки автора. Это не перехват трафика, а запрос, собранный по исходнику:

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

Каждый заголовок нужен серверу, чтобы выбрать ответ:

ЗаголовокОткуда значениеЗачем серверу
Expo-Runtime-VersionruntimeVersion сборки; у автора политика appVersion, то есть version из конфига, 1.0.0Отдать только обновление, совместимое с нативной частью. Этой сверке посвящена следующая статья
Expo-Platformвшито в код: ios или androidБандлы у платформ разные
expo-channel-namerequestHeaders нативного конфига: Expo.plist на iOS, meta-data в AndroidManifest.xml. У автора канал задан в профиле eas.jsonКакую ветку обновлений отдавать; об этом статья про EAS Update
Expo-Current-Update-ID, Expo-Embedded-Update-IDбаза expo-updates на телефонеЧто у телефона уже есть: запущенное обновление и вшитое в сборку
Acceptвшито в кодТелефон понимает multipart/mixed, а для старых серверов — голый JSON
Expo-Protocol-Versionвшито в код: 1Какую версию протокола понимает телефон. Сервер отвечает своим expo-protocol-version, и от него зависит, как читать пустой ответ; в конце статьи это сыграет

Проверка при запуске идёт в нативном коде, раньше JS. console.log вокруг неё не поставить: её итог JS узнаёт уже из API expo-updates.

Ответов бывает три вида. 204 без тела значит «обновления нет». 200 с телом multipart/mixed несёт манифест обновления. То же тело может нести директиву вместо манифеста. Части различаются параметром name в заголовке content-disposition. В исходнике их ровно четыре — manifest, extensions, certificate_chain и directive; часть с другим именем телефон пропускает.

Сети в песочнице нет, поэтому сервер «Подхода» живёт в подложке примеров. Это функция updateServer: она принимает Request и отдаёт Response — те же классы, что объявлены в типах React Native, и работают они здесь так же, как у fetch на телефоне. Формат ответа взят из протокола expo-updates версии 1, значения наши. Вот что сервер возвращает. Манифест здесь для чтения разбит на строки, а сервер шлёт его одной строкой:

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

Прогноз: что напечатает res.json() и сколько частей найдёт parseMultipart?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
res.json() упал с SyntaxError: тело начинается не с фигурной скобки, а с --podhod-7f3a9c. Не спасёт и res.formData(). Браузер разбирает им multipart/form-data, а на multipart/mixed Chrome отвечает TypeError — мы проверили это в той же песочнице. Поэтому разбор приходится писать свой, и телефону тоже. На Android это MultipartReader из OkHttp, на iOS — UpdatesMultipartStreamReader, форк разборщика из самого React Native. Устроены они одинаково: граница берётся из content-type, тело режется по строке --граница, заголовки каждого куска отделены от его тела пустой строкой, а --граница-- означает конец.

Зачем вообще multipart, если манифест — просто JSON? Затем, что рядом с ним едут части, которым внутри манифеста не место: extensions с заголовками для загрузки файлов, certificate_chain для подписи, directive вместо манифеста. Голый JSON телефон тоже примет, недаром application/json стоит в его Accept. Но тогда весь ответ считается манифестом, и места для остального нет.

Порядок частей не важен: телефон выбирает часть по имени. Сервер может прислать extensions раньше манифеста.

Манифест описан типом ExpoUpdatesManifest в пакете expo-manifests 57. Поля такие: id, createdAt, runtimeVersion, launchAsset, assets, metadata и необязательный extra. Какие поля файла телефон требует, видно по ExpoUpdatesUpdate.kt:

Поле файлаБандл, launchAssetКартинки и шрифты, assets[]
keyобязательнообязательно
urlобязательнообязательно
fileExtensionне нужно: в комментарии исходника сказано, что серверы EAS его не присылаютобязательно
hashнеобязательно, но если есть, сверяетсято же

Прочитаем манифест так же, как телефон: найдём часть по имени и разберём JSON. Прогноз: сколько строк «ассет» напечатает пример и окажется ли среди них JS-бандл?

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

Ассетов три, и бандла среди них нет. Он лежит отдельно, в launchAsset: с этого файла приложение запускается, а assets — картинки и шрифты, на которые бандл ссылается. Значит, файлов в обновлении на один больше, чем элементов в assets. И fileExtension у бандла нет: в ExpoUpdatesUpdate.kt прямо сказано, что для файла запуска расширение не нужно и серверы EAS его не присылают.

У каждого файла два отпечатка, и роли у них разные. key — имя. Под этим именем файл ляжет на телефоне: Android хранит его как key плюс fileExtension в своей папке .expo-internal. По этому же ключу телефон узнаёт, что файл у него уже есть. hash — контроль: SHA-256 содержимого в base64url, телефон сверит его после загрузки. Ключ в подложке — MD5 содержимого: таким хешем Metro подписывает каждый ассет при сборке. Поэтому ключ меняется, только когда меняется сам файл.

Теперь то, ради чего ключ нужен. На телефоне стоит сборка из магазина, её файлы вшиты в приложение, и их ключи expo-updates помнит. Обновление поменяло длину таймера отдыха в JS и перерисовало картинку пустого дневника. Прогноз: сколько файлов поедет по сети?

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

Поедут два файла из четырёх: бандл и новая картинка. Гантель и шрифт не изменились, их ключи совпали с вшитыми, качать нечего. В Loader.kt это две проверки. Сперва список сворачивается по ключу, distinctBy { it.key }. Потом для каждого ключа ищется запись в базе и файл на диске. Нашлись — значит ALREADY_EXISTS, и загрузки нет. Бандл же меняется при любой правке JS: даже одна цифра даёт новый файл и новый ключ.

Сколько весит этот новый бандл, разберём в статье про EAS Update, но одна деталь из исходника нужна уже здесь. В SDK 57 телефон, запрашивая бандл, добавляет заголовок A-IM: bsdiff, то есть «могу принять разницу с бандлом, на котором запущен». Ответит сервер разницей — телефон соберёт из неё новый файл и всё равно сверит его с hash из манифеста. Флаг enableBsdiffPatchSupport по умолчанию включён, а отвечать разницей или нет, решает сервер.

Файлы качаются по url обычными GET-запросами, и путь у них длинный: раздача, прокси, мобильная сеть, которая рвётся в лифте. Поэтому каждый файл, прежде чем лечь на место, проходит проверку. verifySHA256AndWriteToFile пишет его во временный файл, считает SHA-256, кодирует в base64url и сравнивает с hash из манифеста. Совпало — временный файл становится настоящим. Не совпало — ошибка, в тексте которой так и сказано: загрузка удалась, а SHA-256 не сошёлся.

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

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

С первой попытки не встало ничего. Бандл пришёл целым и проверку прошёл, но обновление — это манифест целиком. В Loader.kt загрузки идут параллельно, awaitAll() бросает первую же ошибку, а в базу файлы и само обновление записываются только после того, как дождались всех. Полуобновления — нового бандла со старой картинкой — не бывает по построению. Приложение живёт на прежнем, а при следующей проверке всё повторяется. Во второй попытке раздача отдала файл целым, и обновление встало.

hash приходит в том же манифесте, что и ссылка. Кто может подменить ответ сервера, подменит и файл, и его хеш, и проверка пройдёт. От этого защищает подпись манифеста, code signing. Телефон с настроенным сертификатом шлёт в запросе заголовок expo-expect-signature. Сервер кладёт подпись в заголовок expo-signature части с манифестом, а цепочку сертификатов — в часть certificate_chain. Если подпись не сошлась, манифест отвергается раньше, чем телефон прочтёт первую ссылку.

Любой ответ телефон сводит к одному из трёх решений, и в JS оно приходит объектом UpdateCheckResult из типов expo-updates. Первое: есть обновление, isAvailable: true и манифест. Второе: сервер велит откатиться к вшитому в сборку, isRollBackToEmbedded: true. Третье: обновления нет, и в reason лежит причина. Соберём ту же развилку и заодно проверим ответ, который выглядит безобидно: 204 без заголовка версии протокола. Прогноз: что выйдет из каждого из четырёх ответов?

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

Первые три ответа дали то, что задумано: манифест — isAvailable: true, директива rollBackToEmbedded — откат к бандлу из сборки, 204 — noUpdateAvailableOnServer. Директив в RemoteUpdate.kt две: вторая, noUpdateAvailable, говорит «обновления нет» телом ответа, и миниатюра честно сводит её туда же, куда 204. А четвёртый дал ошибку, а не «обновления нет», и это не придирка миниатюры. В FileDownloader.kt пустой ответ считается «нет обновления», только если expo-protocol-version больше нуля; иначе телефон пишет Invalid update response. Если свой сервер обновлений забудет этот заголовок, каждое «нечего обновлять» станет сбоем проверки.

Настоящий телефон делает ещё один шаг, которого в миниатюре нет: прежде чем качать файлы, он пропускает манифест через политику загрузки — LoaderSelectionPolicyFilterAware, одинаковую на Kotlin и Swift. Она отвечает на единственный вопрос: стоит ли вообще тратить сеть на это обновление. Сравниваются четыре вещи: фильтры манифеста, адрес обновлений, заголовки запроса из конфига и commitTime нового обновления против запущенного. Не новее или не отсюда — в JS придёт isAvailable: false с причиной updateRejectedBySelectionPolicy. Время проверяется последним: если запущенное обновление само не проходит фильтры, новое берут без сравнения времени — на этом держится откат переключением канала. А вот runtimeVersion эта политика не смотрит вовсе: такого слова в её коде нет. Поэтому обновление для чужой нативной части, если сервер его отдал, спокойно скачается и ляжет в базу — и всё равно не запустится. Метку сверяет второй замок, уже при запуске; его разберём в следующей статье.

Из JS этот путь запускают три функции expo-updates. В приложении автора их нет — там проверка идёт сама при запуске, — а по кнопке её делают так:

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

Все три отказывают в режиме разработки с ошибкой ERR_UPDATES_DISABLED: в отладочной сборке JS приходит из Metro, и обновлять нечего. В песочницу этот листинг не запустить вовсе: пакеты Expo в неё не собираются, потому что expo-modules-core тянет TurboModuleRegistry, а у react-native-web его нет. А в веб-сборке самого Expo expo-updates — заглушка. checkForUpdateAsync там всегда отвечает noUpdateAvailableOnServer, а перезагрузка — просто window.location.reload. Веб-версия обновляется так же, как любой сайт: новой выкладкой.

«По воздуху» летит только то, что перечислено в манифесте: бандл и ассеты. Нативного кода в нём нет и быть не может.

Выходит, обновление по воздуху — это GET, ответ из частей, JSON со ссылками и несколько загрузок с проверкой хеша. Необъяснённым остался один шаг: почему сервер отдал этот манифест именно этому телефону. Решает он по заголовку Expo-Runtime-Version. В следующей статье мы соберём эту сверку сами и сломаем её, чтобы увидеть отказ.

  • Проверка обновления — обычный GET на updates.url. Его делает нативная часть expo-updates при запуске, до старта JS. В заголовках телефон сообщает то, что нужно серверу для выбора: Expo-Runtime-Version, платформу, канал, id запущенного и вшитого обновлений.
  • Ответ — либо 204 («обновления нет», и только при expo-protocol-version больше нуля), либо multipart/mixed, где части различаются по name: manifest, extensions, certificate_chain, directive. Ни res.json(), ни formData() такое тело не разберут.
  • Манифест — JSON со ссылками, кода в нём нет. Бандл лежит в launchAsset отдельно от картинок и шрифтов в assets, поэтому файлов на один больше, чем элементов в assets.
  • key — имя файла на телефоне и признак «уже есть»: скачивается только незнакомое, обычно это новый бандл и изменённые ассеты. hash — SHA-256 в base64url, телефон сверяет его после загрузки.
  • Обновление встаёт целиком или никак: один несовпавший хеш или одно опасное имя файла — и приложение остаётся на прежнем. От подмены самого манифеста защищает не hash, а подпись.

1/4Обновление поменяло одну строку JS. Что телефон скачает по ссылкам из манифеста?

2/4Свой сервер обновлений на «обновления нет» отвечает 204 без заголовков. Что получит приложение?

3/4При загрузке обновления у одной картинки не сошёлся hash. Что будет?

4/4Где разбирается ответ сервера обновлений, когда приложение запускается?

Заголовки для закрытой раздачи

Файлы обновлений не обязаны лежать в открытом доступе. Если раздача требует авторизации, сервер кладёт нужные заголовки в часть extensions: { "assetRequestHeaders": { "<key>": { … } } }. Телефон добавляет их к запросу того файла, чей это ключ. В подложке — сервер privateServer, который так и делает. Допишите assetRequests: сейчас у всех четырёх файлов заголовков нет, а у бандла должен появиться authorization.

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

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

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

Теперь у бандла { authorization: "Bearer podhod-cdn" }, у остальных {}. У телефона так же: ExpoUpdatesUpdate.kt читает assetRequestHeaders из extensions и кладёт в каждый файл его extraRequestHeaders, а FileDownloader добавляет их к запросу файла. Заголовки выбираются по key, поэтому одна часть extensions обслуживает весь манифест.

Имя файла из чужих рук

Телефон сохраняет файл обновления под именем key + fileExtension в своей папке. Оба поля приходят с сервера. Сервер с ошибкой или чужой сервер пришлёт ключ ../../databases/podhod — и файл ляжет за пределы папки обновлений, например поверх базы дневника. Допишите isSafeFilename так, чтобы из трёх манифестов прошёл только первый.

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

Ровно такую проверку делает expo-updates 57 перед тем, как принять манифест: UpdatesUtils.isSafeFilename требует непустое имя, не . и не .., без /, \ и нулевого символа.

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

Вывод: A принято, B отказ: имя «../../databases/podhod.db», C отказ: имя «..». Отказ — за весь манифест: в ExpoUpdatesUpdate.kt одно плохое имя бросает ошибку, и обновление не загружается вовсе, как и при несовпавшем хеше. Проверять имена надо до первой записи на диск: после неё уже поздно.