Инженерная статья

Проверка встраивания iOS App Extension на облачном Mac

Проверка встраивания iOS App Extension на облачном Mac

В iOS-проектах со службой уведомлений, расширением общего доступа или Widget Extension часто встречается одна и та же проблема: основное приложение успешно компилируется и архивируется, но на этапе экспорта, установки или проверки перед публикацией расширение признаётся недействительным. Обычно причина не в исходном коде, а в расхождении номеров версий, минимальных версий ОС, расположения встроенного компонента или подписей между .app и .appex. Если выполнять проверку сразу после создания архива на облачном Mac, однозначный результат будет получен ещё до того, как артефакт покинет сборочный узел.

Сначала определите критерии приёмки архива

App Extension не является самостоятельным артефактом поставки. Оно должно находиться в каталоге PlugIns основного приложения и образовывать с ним проверяемую связь встраивания. CI должен как минимум проверять следующие параметры:

Проверяемый параметр Основное приложение App Extension Рекомендуемое правило
CFBundleVersion Обязательно Обязательно Полное совпадение
CFBundleShortVersionString Обязательно Обязательно Полное совпадение
MinimumOSVersion Обязательно Обязательно Совпадение, если команда использует единое значение
NSExtension Не требуется Обязательно Словарь существует и доступен для чтения
Подпись кода Действительна Действительна Строгая проверка пройдена
Расположение Applications PlugIns/*.appex Отдельные копии в других местах не допускаются

Минимальная версия ОС не обязана совпадать во всех проектах. Однако если команда намеренно не поддерживает разные целевые версии, требование полного совпадения упрощает обнаружение дрейфа конфигурации. Если различия действительно необходимы, допустимый диапазон следует зафиксировать в репозитории, а не молча игнорировать в скрипте.

«Сборка выполнена успешно» означает лишь, что каждый Target смог создать свой артефакт. «Архив прошёл приёмку» также означает, что эти артефакты правильно связаны и вместе образуют приложение, готовое к поставке.

Создайте единственный xcarchive для проверки

Проверка должна работать с архивом, а не с промежуточным каталогом DerivedData. В DerivedData может сохраниться .appex от предыдущей сборки, а его содержимое не отражает итоговую структуру встраивания. Сначала удалите архив по фиксированному пути, а затем один раз выполните архивирование конфигурации Release:

set -euo pipefail

ARCHIVE_PATH="$PWD/build/App.xcarchive"
rm -rf "$ARCHIVE_PATH"

xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -destination 'generic/platform=iOS' \
  -archivePath "$ARCHIVE_PATH" \
  clean archive

Не позволяйте разным веткам использовать один каталог архивов. Параллельные задания могут создавать отдельные пути на основе хеша коммита или номера задания CI, но последующим скриптам следует передавать только один конкретный .xcarchive.

Сначала проверьте результат встраивания

После архивирования основное приложение обычно находится в Products/Applications, а расширения — в каталоге PlugIns внутри его пакета. Сначала проверьте фактический результат с помощью find:

find build/App.xcarchive/Products/Applications \
  \( -name '*.app' -o -name '*.appex' \) \
  -print

Если в проекте объявлен Target расширения, но .appex не найден, прежде всего проверьте этап сборки Embed App Extensions основного Target и убедитесь, что расширение собирается в текущей Scheme. Не копируйте расширение из другого каталога, чтобы искусственно «дополнить» архив.

Проверьте метаданные и подписи скриптом

Следующий Bash-скрипт совместим со стандартными инструментами macOS. Он находит основное приложение, последовательно читает Info.plist каждого расширения, сравнивает версии и минимальные версии ОС, а затем отдельно проверяет подписи:

#!/bin/bash
set -euo pipefail

ARCHIVE="${1:?usage: validate-extensions.sh App.xcarchive}"
APP=$(find "$ARCHIVE/Products/Applications" -maxdepth 1 -name '*.app' -print -quit)

if [ -z "$APP" ]; then
  printf '%s
' "main app not found"
  exit 1
fi

read_plist() {
  /usr/libexec/PlistBuddy -c "Print :$2" "$1/Info.plist"
}

host_build=$(read_plist "$APP" CFBundleVersion)
host_version=$(read_plist "$APP" CFBundleShortVersionString)
host_minimum=$(read_plist "$APP" MinimumOSVersion)
count=0
failed=0

while IFS= read -r extension; do
  count=$((count + 1))
  ext_build=$(read_plist "$extension" CFBundleVersion)
  ext_version=$(read_plist "$extension" CFBundleShortVersionString)
  ext_minimum=$(read_plist "$extension" MinimumOSVersion)

  [ "$ext_build" = "$host_build" ] || failed=1
  [ "$ext_version" = "$host_version" ] || failed=1
  [ "$ext_minimum" = "$host_minimum" ] || failed=1

  /usr/libexec/PlistBuddy \
    -c "Print :NSExtension" \
    "$extension/Info.plist" >/dev/null

  codesign --verify --strict --verbose=2 "$extension"
done < <(find "$APP/PlugIns" -maxdepth 1 -name '*.appex' -print)

[ "$count" -gt 0 ] || {
  printf '%s
' "no app extensions found"
  exit 1
}

codesign --verify --deep --strict --verbose=2 "$APP"
[ "$failed" -eq 0 ] || {
  printf '%s
' "extension metadata mismatch"
  exit 1
}

Сохраните скрипт как ci/validate-extensions.sh, выполните chmod +x и вызывайте его после этапа архивирования. Если репозиторий создаёт как Scheme «с расширениями», так и Scheme «без расширений», ожидаемое количество расширений следует передавать параметром. Нельзя автоматически считать успешным любой результат с нулевым количеством расширений.

Сведите данные о версиях к настройкам сборки

Чаще всего расхождения возникают из-за ручного сопровождения нескольких файлов Info.plist. После изменения Build Number основного приложения в расширении может остаться прежнее значение, но компиляция исходного кода из-за этого не завершится ошибкой. Более надёжный подход — использовать один набор переменных сборки для всех Target:

MARKETING_VERSION = 4.8.0
CURRENT_PROJECT_VERSION = 8204
IPHONEOS_DEPLOYMENT_TARGET = 17.0

В соответствующих значениях plist используйте $(MARKETING_VERSION), $(CURRENT_PROJECT_VERSION) и $(IPHONEOS_DEPLOYMENT_TARGET). Если конфигурация управляется через .xcconfig, основное приложение и расширения должны подключать общий базовый файл, а немногочисленные ключи, которым действительно требуются разные значения, следует переопределять в отдельных файлах конкретных Target.

Не маскируйте проблему повторной подписью после архивирования

codesign --deep подходит для проверки всего пакета, но не должен использоваться как универсальная команда исправления. Рекурсивная повторная подпись после архивирования может изменить исходные связи встраивания и привести к тому, что артефакт CI перестанет соответствовать конфигурации проекта. При ошибке подписи вернитесь к настройкам подписи Target, Build Phases и параметрам экспорта, исправьте их, а затем заново создайте полный архив.

При ошибке сохраняйте диагностические данные по порядку

После сбоя проверки не очищайте рабочий каталог сразу. Сначала сохраните следующие данные:

  1. относительные пути всех .app и .appex внутри .xcarchive;
  2. номер версии, минимальную версию ОС и Bundle Identifier каждого пакета;
  3. полный вывод codesign --verify --strict --verbose=2;
  4. использованные Scheme и Configuration, а также фактически развёрнутые настройки сборки;
  5. идентификатор коммита и версию Xcode, использованные заданием архивирования.

Настройки сборки можно экспортировать следующей командой, чтобы сравнить переменные, фактически полученные основным приложением и расширением:

xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -showBuildSettings > build/build-settings.txt

Итоговая проверка должна решать две задачи: сообщение об ошибке должно сразу указывать, в каком .appex и какой именно ключ не совпадает, а исправления должны вноситься в конфигурацию проекта, а не после создания артефакта. Тогда при любой замене облачного узла Mac критерии приёмки останутся в репозитории, а результат архивирования можно будет проверять повторно.

Часто задаваемые вопросы

Почему App Extension может дать ошибку после успешного архивирования?

Архивирование подтверждает сборку целей, а экспорт и установка дополнительно проверяют версии, минимальную iOS, метаданные расширения, встраивание и подписи.

Должны ли приложение и расширение иметь одинаковый номер сборки?

Значение CFBundleVersion должно совпадать. CFBundleShortVersionString также лучше формировать из общей централизованной конфигурации.

Можно ли переподписать расширение после создания архива?

Не следует. Исправьте настройки сборки, фазу встраивания или конфигурацию подписи, затем создайте новый архив в чистом рабочем каталоге.

Выделенный физический узел

Перенесите задачи разработки и сборки на выделенный облачный Mac

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

Выбрать тариф облачного Mac