Pull to refresh

Использование Command Line Tool на Swift в iOS проекте

Level of difficultyMedium
Reading time8 min
Views1.8K

Не так давно на проекте возникла необходимость настроить периодическое обновления информации со стороннего сервиса. В моем случае это был файл‑справочник в формате JSON, хранящийся в Bundle приложения, и ещё Info.plist в придачу. Отмечу, что такие файлы нельзя изменять в запущенном iOS приложении. Да и вообще, вызовы напрямую в сторонний сервис уже в рантайме меня не сильно радовали — в случае сбоя загрузки данных у приложения отваливался кусок функциональности.

Как бы вы поступили в этом случае? Вынесли бы кэширование таких файлов на ваш бэк? Таким образом мы бы решили проблему со сторонним нестабильным сервисом, но что делать с Info.plist?

Первое, что приходит в голову — написать Shell‑скрипт. Но это могло привести к проблемам с поддержкой другими iOS разработчиками. Поэтому было принято решение создать утилиту на Swift.

В сети много материала по созданию Command Line Tool на Swift, но мы разберем абстрактный пример создания такой утилиты сразу в связке с iOS проектом.

Описание проекта

Итак, нам необходимо разработать консольную утилиту, обновляющую список доменов для отладки приложения без HTTPS.

... как будто мы не могли просто установить флаг Allow Arbitrary Loads. Но на то он и пример, чтобы потренироваться.

Для этого нам нужно обновить массив NSAppTransportSecurity — NSExceptionDomains в Info.plist, а также файл‑справочник, содержащий информацию о доменах. Пример такого справочника в виде JSON пусть хранится в самом приложении.

[
   {
      "name":"dev.app_main.com",
      "allowsInsecureHTTPLoads":true,
      "description":"DEV environment for app testing"
   },
   ...
]

Исходники всего проекта есть тут. Проект содержит два таргета: iOS приложение DomainsList, которое просто выводит список имеющихся доменов, и утилиту DomainsUpdater.

iOS приложение использует модель Domain для отображения данных, получаемых из JSON справочника:

struct Domain: Decodable {
	let name: String
	let description: String
	let allowsInsecureHTTPLoads: Bool
}

В этом гайде мы пройдемся только по шагам создания утилиты. Начнем!

Настройка

Добавим нашу утилиту в качестве дополнительного таргета проекта, указав тип Command Line Tool. Сделать это можно из настроек проекта, нажав на плюс в блоке Targets.

Далее нам нужно будет удалить файл main.swift, добавленный по умолчанию в новый проект. Вместо него создадим enum следующего вида:

@main
enum DomainsUpdater {
	static func main() {}
}

В итоге должно получиться вот так:

Теперь статическая функция main будет являться точкой входа для нашей утилиты.

Вместо enum можно также использовать и другие типы, но главное, чтобы точка входа в программу была одна. В принципе для простой утилиты как наша можно было обойтись файлом main.swift, но использование атрибута main более современный подход.

Воспользуемся преимуществом шаринга кода между таргетами проекта и добавим модель Domain сразу для утилиты и iOS приложения

struct Domain: Decodable {
	let name: String
	let description: String
	let allowsInsecureHTTPLoads: Bool
}

Подготовительный этап завершен! Теперь давайте приступим к работе с входными параметрами.

Работа с входными параметрами и окружением

Заранее скажу, что в статье не будет рассматриваться подход с использованием ArgumentParser. Мы постараемся обойтись более простыми средствами, так как основная задача — написать хэлпер, а не полноценное CLI приложение. Также, если вы не используете SPM в качестве менеджера зависимостей, чтобы подключить ArgumentParser, то использование средств «из коробки» может оказаться подходящим решением.

Класс CommandLine — это один из вариантов получения информации утилитой извне. У него есть статическое свойство arguments в виде массива строк.

Но тут нас ожидает подножка от Apple. Для данного свойства забыли написать доку, а есть один очень важный момент: нулевой элемент arguments является именем нашей утилиты!

Однако, есть и другой класс — ProcessInfo, в котором про документацию НЕ забыли. У него есть точно такое же свойство arguments, а также свойство environment для получения данных о переменных окружения. Разницу, какой из классов CommandLine или ProcessInfo использовать, я не обнаружил. Чтобы облегчить работу с массивом аргументов, я сделал небольшую обертку:

typealias ArgumentType = RawRepresentable & Hashable

struct ArgumentsProvider<T: ArgumentType> {
	private let arguments: [T: String]
  
	init(_ argumentsArray: [String]) {
		var argumentsDictionary: [T: String] = [:]
		argumentsArray.enumerated().forEach { index, arg in
			guard let index = index as? T.RawValue, 
                  let argKey = T(rawValue: index) else { 
              return 
            }
			argumentsDictionary[argKey] = arg
		}
		self.arguments = argumentsDictionary
	}

	subscript(arg: T) -> String {
		guard let arg = arguments[arg], !arg.isEmpty else { 
          fatalError(.argumentNotFound(arg)) 
        }
		return arg
	}
}

Для удобства отладки тестового проекта надо позаботиться о том, чтобы откатывать сделанные изменения. Поэтому утилита имеет две команды: update и clean. update — основная команда, которая изменит файлы проекта domains.json (где хранится полная инфа о доменах) и Info.plist, а clean соответственно вернёт файлам первоначальный вид.

static func main() {
    let args = ArgumentsProvider<CommandArgument>(ProcessInfo.processInfo.arguments)
    guard let command = Command(rawValue: args[.command]) else { 
      fatalError(.unrecognizedCommand) 
    }

    switch command {
    case .update:
        update()
    case .clean:
        clean()
    }
}

static func update() {
    let args = ArgumentsProvider<UpdateArgument>(ProcessInfo.processInfo.arguments)
    let domains = fetchDomainsJson(remotePath: args[.remotePath])
    updateLocalFile(at: args[.localPath], domains: domains)
    updateInfoPlist(at: args[.infoPlistPath], domains: domains)
}

static func clean() {
    let args = ArgumentsProvider<CleanArgument>(ProcessInfo.processInfo.arguments)
    updateLocalFile(at: args[.localPath], domains: [])
    updateInfoPlist(at: args[.infoPlistPath], domains: [])
}
Hidden text

Реализацию методов fetchDomainsJson, updateLocalFile и updateInfoPlist вы можете найти в репозитории.

Тип CommandArgument хранит индекс аргумента команды, а сама команда имеет 2 значения: update или clean.

enum CommandArgument: Int {
	case command = 1
}

enum Command: String, CaseIterable {
	case update
	case clean
}

Типы UpdateArgument, CleanArgument используются для получения аргументов конкретных команд.

enum UpdateArgument: Int {
	case remotePath = 2
	case localPath
	case infoPlistPath
}

enum CleanArgument: Int {
	case localPath = 2
	case infoPlistPath
}

Вместо передачи некоторых аргументов можно воспользоваться переменными окружения ProcessInfo.processInfo.environment. Например, чтобы получить путь к файлу domains.json или Info.plist.

guard let srcRoot = ProcessInfo.processInfo.environment["SRCROOT"] else { 
  fatalError("SRCROOT not found") 
}

let resources = "\(srcRoot)/DomainsList/Resources"
let localPath = "\(resources)/domains.json"
let infoPlistPath = "\(resources)/Info.plist"

ВАЖНО!!! данный способ будет работать, только если вы запустите вашу утилиту из секции Build Phases при сборке проекта, потому что только тогда переменные окружения проекта будут заданы. Отлаживать утилиту таким способом тоже можно, установив при запуске Debug сборки нужные переменные окружения. Но я предпочитаю более наглядный способ через передачу аргументов.

Раз мы уже заговорили про отладку, то перейдем, наверное, к самой интересной части, а именно к способам запуска утилиты.

Запуск утилиты

Отладка

Для отладки нам потребуется указать аргументы в настройках схемы. Тут у нас появляется возможность использовать переменные окружения. НО!!! Будьте внимательны, потому что некоторые переменные, такие как INFOPLIST_FILE, меняются в зависимости от окружения. Например, для схемы DomainsUpdater значение будет пустым. Используйте более универсальный вариант типа SRCROOT.

Pre-compile скрипт в Build Phases

Для начала добавим нашу утилиту в явные зависимости iOS проекта:

ВАЖНО!!! Установите флаг Skip install в Build Settings. Иначе при сборке релиза получим на выходе архив, содержащий 2 исполняемых файла, который невозможно загрузить в AppStore Connect.

Затем добавим сам скрипт, вызывающий собранную утилиту с параметрами перед фазой Compile Sources:

if [ $CONFIGURATION = 'Debug' ] 
then
	$CONFIGURATION_BUILD_DIR/../$CONFIGURATION/DomainsUpdater update \
	"https://raw.githubusercontent.com/Streetmage/DomainsList/main/remote_domains.json" \
	"$SRCROOT/DomainsList/Resources/domains.json" \
	"$SRCROOT/DomainsList/Resources/Info.plist"
fi

Как видно, наш скрипт запустится только для Debug конфигурации, но вы можете указать вместо этого Release, чтобы скрипт срабатывал перед созданием архива проекта.

Pre‑actions в настройке схемы

Казалось бы, есть ещё один способ запуска утилиты — через установку скрипта до старта проекта:

Однако, pre‑action скрипт выполняется уже непосредственно перед запуском приложения после сборки, и соответственно только со второго запуска (или создания архива) файлы проекта будут изменены.

Shell-скрипт для сборки и запуска

Всё‑таки полностью от Shell скрипта не откажешься. Нам может потребоваться использовать утилиту периодически с настройкой времени запуска на стороне CI. Тогда это можно просто сделать при помощи вот такого скрипта:

# Build utility
TARGET="DomainsUpdater"
CONFIG="Debug"
xcodebuild -project DomainsList.xcproject -scheme $TARGET -configuration $CONFIG -destination 'generic/platform=macOS'
# Get BUILD_ROOT and SRCROOT from utility project settings
BUILD_SETTINGS=`xcodebuild -project DomainsList.xcproject -scheme $TARGET -showBuildSettings`
BUILD_ROOT=`echo "$BUILD_SETTINGS" | grep BUILD_ROOT | sed -e "s/^    BUILD_ROOT = //"`
SRCROOT=`echo "$BUILD_SETTINGS" | grep SRCROOT | sed -e "s/^    SRCROOT = //"`
# Start utility
$BUILD_ROOT/$CONFIG/$TARGET update \
"https://raw.githubusercontent.com/Streetmage/DomainsList/main/domains.json" \
"$SRCROOT/DomainsList/Resources/domains.json" \
"$SRCROOT/DomainsList/Resources/Info.plist"

ВАЖНО!!! Не забудьте сделать свой файл .sh исполняемым:

chmod +x DomainsUpdater.sh

Выводы

При помощи Command Line Tool можно обновлять редко изменяемые справочники проекта, не задействуя при этом ваш бэкенд. Если какой‑либо справочник вы запрашиваете у стороннего сервиса, способного упасть в неподходящий момент, то лучше настроить периодическое обновление, чем делать вызов из самого приложения.

Когда лучше использовать Swift-утилиту, а когда лучше писать скрипт

Утилита:

  • когда нужно сделать простые действия с файлами проекта, а изучать консольные команды не хочется

  • простота отладки в Xcode

  • шаринг кода между проектом и утилитой

  • поддержка утилиты другими iOS разработчиками

Скрипт:

  • у вас есть готовый набор утилит, который просто нужно вызвать через Shell

  • вы владеете консольными командами

  • действия, которые нужно совершить над проектом не требуют специфических знаний по iOS, чтобы поддержка скрипта могла осуществляться DevOps инженерами

P. S. В демо проекте список доменов обновится при запуске приложения DomainsList. Для того, чтобы очистить domains.json и Info.plist, запустите утилиту DomainsUpdater в дебаг режиме: в нём по умолчанию аргументы настроены на команду clean. Если у вас возникли проблемы с запуском скрипта DomainsUpdater.sh, то в README репозитория вы, возможно, найдете решение.

Tags:
Hubs:
Total votes 2: ↑1 and ↓10
Comments28

Articles