Не так давно на проекте возникла необходимость настроить периодическое обновления информации со стороннего сервиса. В моем случае это был файл‑справочник в формате 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 репозитория вы, возможно, найдете решение.
Комментарии (28)
storoj
17.05.2023 12:00Ещё рекомендую посмотреть
man plutil
иman PlistBuddy
. PlistBuddy почему-то никогда не попадает в $PATH, но он всегда есть в/usr/libexec/PlistBuddy
.Как я понимаю, задача заточена чисто под iOS, и а раз так, то надо и делать всё так, чтобы было максимально удобно для имеющихся инструментов. Например вместо json с самодельным форматом можно было бы сразу хранить нужную ветку Info.plist в формате plist (или json), и делать
PlistBuddy -x "Merge remote.plist"
. Но у PlistBuddy есть фатальный недостаток: он когда видит несуществующий ключ, возвращает ненулевой код ошибки. Это неудобно.Но можно сделать почти идеально с plutil:
plutil -replace 'NSAppTransportSecurity' -json "{ ... }" -- Info.plist
Вот формат, который мы на самом деле хотим видеть в "настроечном" удалённом файле:
{ "NSExceptionDomains": { "dev.app_main.com": { "NSExceptionAllowsInsecureHTTPLoads" : true }, "dev.app_quicksearch.com": { "NSExceptionAllowsInsecureHTTPLoads" : true }, "dev.app_cached.com": { "NSExceptionAllowsInsecureHTTPLoads" : true }, "stage.app_main.com": { "NSExceptionAllowsInsecureHTTPLoads" : false }, "stage.app_quicksearch.com": { "NSExceptionAllowsInsecureHTTPLoads" : false }, "stage.app_cached.com": { "NSExceptionAllowsInsecureHTTPLoads" : false } } }
Остаётся только скачать его и обновить ключ в Info.plist используя plutil:
plutil -replace 'NSAppTransportSecurity' -json "$(curl https://raw.githubusercontent.com/Streetmage/DomainsList/main/remote_domains.json)" -- Info.plist
Чтобы очистить ключ:
plutil -replace 'NSAppTransportSecurity' -json '{}' -- Info.plist
Можно было бы делать и
plutil -remove 'NSAppTransportSecurity' -- Info.plist
, но plutil тоже будет возвращать код ошибки, если ключа не было, это может быть неудобно.$ plutil -remove 'NSAppTransportSecurity' -- Info.plist Info.plist: Could not modify plist, error: No value to remove at key path NSAppTransportSecurity $ echo $? 1 $ plutil -replace 'NSAppTransportSecurity' -json '{}' -- Info.plist $ echo $? 0
storoj
17.05.2023 12:00Вместо отдельного "файла-справочника" в приложении, можно читать эти данные прямо из своего же Info.plist. Я подозреваю, что iOS не обидится, если помимо
NSExceptionAllowsInsecureHTTPLoads
записать ещё и description.storoj
17.05.2023 12:00+1Кажется, так и есть:
print(Bundle.main.object(forInfoDictionaryKey: "NSAppTransportSecurity")) Optional({ NSExceptionDomains = { "mydomain.com" = { MyDescription = "Hello World"; NSExceptionAllowsInsecureHTTPLoads = 1; }; }; })
storoj
17.05.2023 12:00+1Но вообще, я не понимаю суть проблемы. Скорее всего это всё находится на подконтрольных серверах, обновляют этот json люди, которые знают и о приложении, и о доменах, и о разработчиках. Info.plist модифицируется при сборке, нужен для отладки. Так может надо просто научить тех, кто меняет домены, пойти в репозиторий приложения и закомитить изменения в Info.plist? Зачем нужна вся эта шайтан-машина со скриптами?
storoj
17.05.2023 12:00Если вы не хотите, чтобы эти домены попадали в релизную сборку, можно включить в Build Settings настройку
Preprocess Info.plist File (INFOPLIST_PREPROCESS)
, добавить в Info.plistPreprocessor Definitions (INFOPLIST_PREPROCESSOR_DEFINITIONS)
строчку типа "DEBUG" для Debug версии, и тогда в Info.plist можно будет сделать так:<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> #if DEBUG <key>NSAppTransportSecurity</key> <dict> <key>NSExceptionDomains</key> <dict> <key>mydomain.com</key> <dict> <key>NSExceptionAllowsInsecureHTTPLoads</key> <true/> <key>MyDescription</key> <string>Hello World</string> </dict> </dict> </dict> #endif <key>CFBundleDevelopmentRegion</key> <string>$(DEVELOPMENT_LANGUAGE)</string> ...
Я сейчас впервые в жизни это попробовал сделать, и у меня заработало. Однако, боюсь, что plutil с макросами в теле файла уже не сможет работать. Но я и склоняю к тому, чтобы это менять руками, такие обновления происходят очень редко, и на мой взгляд лучше бы их было видно в истории изменений.
Streetmage Автор
17.05.2023 12:00Пример здесь абстрактный и больше был призван показать сам процесс возможности использования утилиты на Swift-е. Он упрощен специально, чтобы не вносить доп. условий.
В исходной задаче файл json привести сразу к формату plist-а было невозможно, это был сторонний сервис. Плюс был нюанс с тем, что не все поля нужно заменять в массиве, а только те, у которых в названии есть определенный признак, то есть ещё дополнительно предварительно отфильтровать.
На этом этапе я решил, что использование консольных утилит типа PlistBuddy потребует их более глубокого изучения и встанет вопрос поддержки другими iOS разработчиками. Например, выше вроде бы простое решение с утилитами plutil и PlistBuddy всё равно требует знания нюансов и опыта работы с этими утилитами.
А вот ваше решение с тем, чтобы хранить дополнительную инфу сразу в plist и не хранить отдельно JSON интересное. Хотя для нас бы не сработало, потому что массив состоял из строк, а не из словарей, куда можно ещё дополнительно закинуть тот же description.
storoj
17.05.2023 12:00Хотя для нас бы не сработало, потому что массив состоял из строк, а не из словарей, куда можно ещё дополнительно закинуть тот же description.
Как это понять?
Streetmage Автор
17.05.2023 12:00Например, вот так:
<array> <string>value_without_filter_tag</string> <string>value_with_filter_tag_1</string> <string>value_with_filter_tag_2</string> <string>value_with_filter_tag_3</string> <array>
И нужно было оставить всё, что без value_without_filter_tag. А все другие значение с подстрокой "with_filter_tag" заменить.
storoj
17.05.2023 12:00Я не понимаю, какой массив, из каких строк, и почему что-то нельзя с ним сделать?
Я предлагал читать данные о доменах (видимо для отображения в UI) прямо из Info.plist, причём туда можно записать не только имя домена и флаг NSExceptionAllowsInsecureHTTPLoads, но так же и любые другие пользовательские данные, как тот же description. Да, структура будет чуть другая, ну и что? Вместо массива объектов типа
[{ domain, description, flag }]
будет структура{ domain: { description, flag} }
. Попарсить её нет никакой проблемы, зато есть один источник правды.Streetmage Автор
17.05.2023 12:00В рамках доменов и данного примера решение то, что надо, с хранением всего в Info.plist. Но если будет просто массив строк, то всё равно придется хранить отдельный json файл.
storoj
17.05.2023 12:00Глядя на код в репозитории, я увидел множество проблем с этим примером. Какие-то касаются кода, какие-то более концептуальные. Я даже и не знаю с чего начать.
Начну пожалуй с любимого:
static func fetchDomainsJson(remotePath: String) -> [Domain] { guard let url = URL(string: remotePath) else { fatalError(.remotePathIsInvalid) } do { let data = try Data(contentsOf: url) return try JSONDecoder().decode([Domain].self, from: data) } catch { fatalError(.failedToLoadRemoteFile(error)) } }
Обожаю эти
do { try f() } catch { fatalError(error) }
. Почему бы просто не сделатьtry!
– система точно так же "упадёт", и точно так же напечатает ошибку.То же касается и url. По-моему, хорошим тоном было бы сразу дать функции нормальные данные (готовый URL), а не заставлять её пытаться из потенциального мусора создать себе нормальное окружение.
Напоследок, раз уж тут есть какие-то try, то пусть бы функция была throws, а делать ли try! пусть решают уровнем выше.
storoj
17.05.2023 12:00Похожий, но другой пример проблемы:
guard let data = try? Data(contentsOf: url), let infoPlist = String(data: data, encoding: .utf8) else { fatalError(.infoPlistNotFound) }
Если
do { try f() } catch { fatalError() }
иf()!
это в принципе одно и то же (потому что одинаково напечатается одинаковая ошибка и одинаково упадёт), то здесь же стало хуже. try! Data(...) напечатал бы гораздо больше полезной информации о том, почему же не получилось прочитать данные, чем просто "infoPlistNotFound". Что ещё и не факт, что правда. Файл-то может быть found, но не смочь прочитаться по тысяче причин: недостаточно прав, прочитался только до половины, да чёрт ещё знает почему. Полезная информация, которая могла бы быть в логах, и помочь расследованию, оказывается просто потеряна.
storoj
17.05.2023 12:00Итак, вот мой вариант:
import Foundation let infoPlistURL = URL(fileURLWithPath: UserDefaults.standard.string(forKey: "infoPlist") ?? "Info.plist") var format: PropertyListSerialization.PropertyListFormat = .xml var plist = try! PropertyListSerialization .propertyList(from: try! Data(contentsOf: infoPlistURL), options: .mutableContainersAndLeaves, format: &format) as! NSMutableDictionary let kvPairs = try JSONDecoder() .decode([DomainInfo].self, from: try readDomainsData()) .map { ($0.name, [ "NSExceptionAllowsInsecureHTTPLoads": $0.allowsInsecureHTTPLoads, "MyDescription" : $0.description ]) } plist["NSAppTransportSecurity"] = [ "NSExceptionDomains": Dictionary(uniqueKeysWithValues: kvPairs) ] try! PropertyListSerialization .data(fromPropertyList: plist, format: format, options: 0) .write(to: infoPlistURL) struct DomainInfo: Decodable { let name: String let allowsInsecureHTTPLoads: Bool let description: String } func readDomainsData() throws -> Data { if let url = UserDefaults.standard.string(forKey: "from"), url != "-" { if url.hasPrefix("http://") || url.hasPrefix("https://") { return try Data(contentsOf: URL(string: url)!) } return try Data(contentsOf: URL(fileURLWithPath: url)) } return FileHandle.standardInput.readDataToEndOfFile() }
мы вроде согласились, что отдельный файл не нужен, поэтому обновляется только Info.plist;
Info.plist по дефолту ищется в текущей рабочей директории, или его можно передать аргументом
-infoPlist
;-
json с данными может читаться из трёх источников:
updater -from https://....
– URL в интернете;updater -from /local/path/on/disk
– файл на диске;-
updater -from -
(dash) или же без аргументов – прочитается из stdin.curl url | updater
;cat file | updater
;updater < file
;updater
и ввод с завершающим Ctrd+D;
команда для удаления тоже не нужна, т.к. это просто "записать пустой массив", и это можно сделать как
echo "[]" | updater
;сохраняет тот же формат файла Info.plist (xml или бинарный).
storoj
Рекомендую открыть для себя https://github.com/apple/swift-argument-parser.
storoj
... а так же NSPropertyListSerialization
Streetmage Автор
Спасибо за совет! Это будет получше регулярок.
Streetmage Автор
Тут дополню, а разве при использовании метода
+ writePropertyList:toStream:format:options:error:
у нас порядок элементов не изменится в Info.plist, так как корневой элемент это Dictionary? Тогда, возможно, что регулярка будет всё же лучше.
storoj
Какой ещё порядок элементов? У словаря нет понятия "порядок элементов".
Streetmage Автор
В том и дело, что порядка нет. То есть при изменении Info.plist методом + writePropertyList:toStream:format:options:error: сам файл будет полностью меняться. И получится, что каждый раз при сохранении ключи перемешиваются.
storoj
Ну и что? Никого же не смущает, что в Dictionary и NSDictionary никогда не было никакого порядка.
storoj
Разве что это проблема для тех, кто парсит такие файлы регулярками, но это исправимо.
Streetmage Автор
Про ArgumentParser я упомянул в статье, что если менеджер зависимостей не SPM и подключать его не хочется, то можно воспользоваться уже доступными способами.
storoj
но зачем вообще что-то парсить, если есть тот же Environment с именованными параметрами?
Streetmage Автор
Переменные окружения проекта будут доступны, если запустить скрипт из Build Phases секции проекта. А если потребуется запустить скрипт уже из другого места, придется их самом прописывать? Тогда уже лучше выглядит передача параметров.
storoj
или же можно использовать UserDefaults: аргументы вида "-arg value" доступны из UserDefaults.standard
Streetmage Автор
Возможность использовать
UserDefaults
, как я понял, не указана явно в публичной документации. Но способ получше парсинга.storoj
У UserDefaults есть разные "domains", и это так называемый Argument Domain: https://developer.apple.com/documentation/foundation/userdefaults/1410665-argumentdomain