Не так давно на проекте возникла необходимость настроить периодическое обновления информации со стороннего сервиса. В моем случае это был файл‑справочник в формате 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)


  1. storoj
    17.05.2023 12:00

    Рекомендую открыть для себя https://github.com/apple/swift-argument-parser.


    1. storoj
      17.05.2023 12:00

      ... а так же NSPropertyListSerialization


      1. Streetmage Автор
        17.05.2023 12:00

        Спасибо за совет! Это будет получше регулярок.


      1. Streetmage Автор
        17.05.2023 12:00
        -1

        Тут дополню, а разве при использовании метода + writePropertyList:toStream:format:options:error:

        у нас порядок элементов не изменится в Info.plist, так как корневой элемент это Dictionary? Тогда, возможно, что регулярка будет всё же лучше.


        1. storoj
          17.05.2023 12:00

          Какой ещё порядок элементов? У словаря нет понятия "порядок элементов".


          1. Streetmage Автор
            17.05.2023 12:00

            В том и дело, что порядка нет. То есть при изменении Info.plist методом + writePropertyList:toStream:format:options:error: сам файл будет полностью меняться. И получится, что каждый раз при сохранении ключи перемешиваются.


            1. storoj
              17.05.2023 12:00

              Ну и что? Никого же не смущает, что в Dictionary и NSDictionary никогда не было никакого порядка.


            1. storoj
              17.05.2023 12:00

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


    1. Streetmage Автор
      17.05.2023 12:00

      Про ArgumentParser я упомянул в статье, что если менеджер зависимостей не SPM и подключать его не хочется, то можно воспользоваться уже доступными способами.


      1. storoj
        17.05.2023 12:00
        +1

        но зачем вообще что-то парсить, если есть тот же Environment с именованными параметрами?

        URL="https://..."" INFO_PLIST="Info.plist" updater update


        1. Streetmage Автор
          17.05.2023 12:00

          Переменные окружения проекта будут доступны, если запустить скрипт из Build Phases секции проекта. А если потребуется запустить скрипт уже из другого места, придется их самом прописывать? Тогда уже лучше выглядит передача параметров.


      1. storoj
        17.05.2023 12:00

        или же можно использовать UserDefaults: аргументы вида "-arg value" доступны из UserDefaults.standard

        updater -arg value
        
        print(UserDefaults.standard.value(forKey: "arg")) -> Optional(value)


        1. Streetmage Автор
          17.05.2023 12:00

          Возможность использовать UserDefaults, как я понял, не указана явно в публичной документации. Но способ получше парсинга.


          1. storoj
            17.05.2023 12:00
            +1

            У UserDefaults есть разные "domains", и это так называемый Argument Domain: https://developer.apple.com/documentation/foundation/userdefaults/1410665-argumentdomain


  1. 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


  1. storoj
    17.05.2023 12:00

    Вместо отдельного "файла-справочника" в приложении, можно читать эти данные прямо из своего же Info.plist. Я подозреваю, что iOS не обидится, если помимо NSExceptionAllowsInsecureHTTPLoads записать ещё и description.


    1. storoj
      17.05.2023 12:00
      +1

      Кажется, так и есть:

      print(Bundle.main.object(forInfoDictionaryKey: "NSAppTransportSecurity"))
      
      Optional({
          NSExceptionDomains =     {
              "mydomain.com" =         {
                  MyDescription = "Hello World";
                  NSExceptionAllowsInsecureHTTPLoads = 1;
              };
          };
      })
      


  1. storoj
    17.05.2023 12:00
    +1

    Но вообще, я не понимаю суть проблемы. Скорее всего это всё находится на подконтрольных серверах, обновляют этот json люди, которые знают и о приложении, и о доменах, и о разработчиках. Info.plist модифицируется при сборке, нужен для отладки. Так может надо просто научить тех, кто меняет домены, пойти в репозиторий приложения и закомитить изменения в Info.plist? Зачем нужна вся эта шайтан-машина со скриптами?


    1. storoj
      17.05.2023 12:00

      Если вы не хотите, чтобы эти домены попадали в релизную сборку, можно включить в Build Settings настройку Preprocess Info.plist File (INFOPLIST_PREPROCESS), добавить в Info.plist Preprocessor 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 с макросами в теле файла уже не сможет работать. Но я и склоняю к тому, чтобы это менять руками, такие обновления происходят очень редко, и на мой взгляд лучше бы их было видно в истории изменений.


    1. Streetmage Автор
      17.05.2023 12:00

      Пример здесь абстрактный и больше был призван показать сам процесс возможности использования утилиты на Swift-е. Он упрощен специально, чтобы не вносить доп. условий.

      В исходной задаче файл json привести сразу к формату plist-а было невозможно, это был сторонний сервис. Плюс был нюанс с тем, что не все поля нужно заменять в массиве, а только те, у которых в названии есть определенный признак, то есть ещё дополнительно предварительно отфильтровать.

      На этом этапе я решил, что использование консольных утилит типа PlistBuddy потребует их более глубокого изучения и встанет вопрос поддержки другими iOS разработчиками. Например, выше вроде бы простое решение с утилитами plutil и PlistBuddy всё равно требует знания нюансов и опыта работы с этими утилитами.

      А вот ваше решение с тем, чтобы хранить дополнительную инфу сразу в plist и не хранить отдельно JSON интересное. Хотя для нас бы не сработало, потому что массив состоял из строк, а не из словарей, куда можно ещё дополнительно закинуть тот же description.


      1. storoj
        17.05.2023 12:00

        Хотя для нас бы не сработало, потому что массив состоял из строк, а не из словарей, куда можно ещё дополнительно закинуть тот же description.

        Как это понять?


        1. 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" заменить.


          1. storoj
            17.05.2023 12:00

            Я не понимаю, какой массив, из каких строк, и почему что-то нельзя с ним сделать?

            Я предлагал читать данные о доменах (видимо для отображения в UI) прямо из Info.plist, причём туда можно записать не только имя домена и флаг NSExceptionAllowsInsecureHTTPLoads, но так же и любые другие пользовательские данные, как тот же description. Да, структура будет чуть другая, ну и что? Вместо массива объектов типа [{ domain, description, flag }] будет структура { domain: { description, flag} }. Попарсить её нет никакой проблемы, зато есть один источник правды.


            1. Streetmage Автор
              17.05.2023 12:00

              В рамках доменов и данного примера решение то, что надо, с хранением всего в Info.plist. Но если будет просто массив строк, то всё равно придется хранить отдельный json файл.


              1. storoj
                17.05.2023 12:00

                Если будет "просто массив строк", то можно его прямо в исходниках и хранить.


      1. 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! пусть решают уровнем выше.


        1. 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, но не смочь прочитаться по тысяче причин: недостаточно прав, прочитался только до половины, да чёрт ещё знает почему. Полезная информация, которая могла бы быть в логах, и помочь расследованию, оказывается просто потеряна.


  1. 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 или бинарный).