Распространение консольных приложений macOS до конечных пользователей

в 21:39, , рубрики: Без рубрики

Начиная с macOS 10.15 Catalina разработчикам требуется заверять свои приложения и утилиты командной строки. Если с приложениями, распространяемыми через App Store все прозрачно, то распространение консольных утилит может вызвать сложности. В данной статье я покажу, как можно доставлять такие утилиты до конечных пользователей (будем проделывать все необходимые операции в терминале, а также автоматизируем эти действия двумя вариантами - через bash-скрипт и с помощью SPM executable).

Создание консольного приложения в Xcode

Для начала создадим проект предельно простой консольной утилиты, которая будет уметь выводить в консоль ascii-графику с грустным котиком:

Распространение консольных приложений macOS до конечных пользователей - 1

Даем проекту название:

Распространение консольных приложений macOS до конечных пользователей - 2

Можем задать произвольное название для компилированной утилиты (например, если проект назван с заглавной буквы, а утилита должна содержать только строчные буквы):

Распространение консольных приложений macOS до конечных пользователей - 3

Нам также потребуется библиотека для удобной обработки команд из терминала Swift Argument Parser:

Распространение консольных приложений macOS до конечных пользователей - 4

В файле main.swift подключим библиотеку Argument Parser и создадим главную команду:

import ArgumentParser
import Foundation

struct SadCat: ParsableCommand {
    static let configuration: CommandConfiguration {
        .init(
            commandName: "sadcat",
            abstract: "Displays a sad cat in the console",
            version: "1.0.0",
            subcommands: [
                AsciiCat.self
            ]
        )
    }
}

SadCat.main()

Планируется, что нашей утилитой из терминала будут пользоваться так: sadcat show. AsciiCat.self - это как раз и будет субкомандой show. Реализуем субкоманду:

struct AsciiCat: ParsableCommand {
    static let configuration: CommandConfiguration {
        .init(
            commandName: "show",
            abstract: "Show ascii art with sad cat"
        )
    }
    
    func run() throws {
        let cat = """
        
             />  フ
             |  _  _|
             /`ミ _x 彡
             /      |
            /  ヽ   ノ
         / ̄|   | | |
         | ( ̄ヽ__ヽ_)_)
         \二つ

        """
        
        print(cat)
    }
}

Все - демо-проект готов!

В процессе разработки удобно в схеме таргета передавать аргументы, которые мы планируем в будущем использовать в терминале:

Распространение консольных приложений macOS до конечных пользователей - 5
Распространение консольных приложений macOS до конечных пользователей - 6

Тогда, выполняя команду Run, можно сразу увидеть результат работы утилиты с переданными аргументами в консоли:

Распространение консольных приложений macOS до конечных пользователей - 7

Релизную версию сборки утилиты командной строки можно собрать, выполнив из меню Xcode Product -> Archive:

Распространение консольных приложений macOS до конечных пользователей - 8
Распространение консольных приложений macOS до конечных пользователей - 9

В окне Window -> Organizer, щелкнув правой кнопкой мыши по архиву, можно посмотреть его расположение:

Распространение консольных приложений macOS до конечных пользователей - 10
Распространение консольных приложений macOS до конечных пользователей - 11
Распространение консольных приложений macOS до конечных пользователей - 12

Путь к папке Products внутри архива нам еще потребуется.

Распространяем свое консольное приложение

Есть несколько возможных путей, как запустить консольную утилиту на стороннем компьютере (конечный пользователь) - вручную переместить утилиту в целевую папку и выдать соответствующие разрешения на ее исполнение, упаковать в заверенный pkg-инсталлятор и через Homebrew.

Путь 1 - Перемещаем скомпилированную утилиту в целевую папку

Итак, пользователь скачал вашу консольную утилиту. Чтобы запускать ее через терминал как sadcat show (т.е. без прописывания полного пути до исполняемой утилиты), ее нужно переместить в папку /usr/local/bin/:

% cd путь_до_скачанной_утилиты
% cp -f sadcat /usr/local/bin/sadcat

Также утилиту можно переместить простым перетаскиванием файла в Finder, открыв предварительно целевую папку: open /usr/local/bin/.

Если сейчас попробовать выполнить команду sadcat show, мы увидим ошибку:

zsh: permission denied: sadcat

Необходимо выдать разрешение на выполнение утилиты:

% chmod u+x /usr/local/bin/sadcat

Снова пробуем выполнить команду sadcat show, и видим ошибку:

Распространение консольных приложений macOS до конечных пользователей - 13
zsh: killed     sadcat show

Необходимо еще перейти в настройки macOS в раздел Защита и безопасность и разрешить использование утилиты:

Распространение консольных приложений macOS до конечных пользователей - 14

Вот теперь можно пользоваться нашей утилитой из терминала:

% sadcat show

     />  フ
     |  _  _|
     /`ミ _x 彡
     /      |
    /  ヽ   ノ
 / ̄|   | | |
 | ( ̄ヽ__ヽ_)_)
 \二つ

Такой способ распространения консольной утилиты уж точно нельзя назвать user-friendly. Поэтому переходим ко второму способу (заверенный pkg-инсталлятор).

Путь 2 - Упаковка в заверенный pkg-инсталлятор

Прежде, чем приступать непосредственно к созданию инсталлятора, нужно создать два сертификата Developer ID Application и Developer ID Installer (если их у вас еще нет), а также Application Specific Password (вы должны быть участником Apple Developer Program):

  1. Создаем два сертификата - Developer ID Application и Developer ID Installer. Их можно создать на портале Apple, или через Xcode: Xcode -> Preferences -> Accounts -> Manage Certificates

  2. Создаем Application Specific Password на портале https://appleid.apple.com/. Он потребуется нам для использования команды altool. Также добавляем в связку ключей только что созданный пароль и указываем:

  • Keychain Item Name: Developer-altool

  • Account Name: электронная почта вашего аккаунта разработчика

  • Password: только что созданный вами Application Specific Password

Также нужно внести некоторые изменения в Xcode-проект:

Отключаем Automatically manage signing, задаем Bundle Identifier и в Signing Certificate обязательно выбираем Developer ID Application:

Распространение консольных приложений macOS до конечных пользователей - 15

Убеждаемся, что значение параметра Hardened Runtime в Build Settings выставлено в Yes. Включение Hardened Runtime приведет к компиляции двоичного файла таким образом, что внешнему процессу будет сложнее инжектировать код. Это обязательное условие для успешного заверения ваших утилит сервером Apple:

Распространение консольных приложений macOS до конечных пользователей - 16

Создаем pkg

Утилиту командной строки невозможно заверить, но можно заверить pkg, dmg или zip файл, внутри которых будет содержаться ваша утилита. pkg можно создать командой pkgbuild:

% pkgbuild --root путь_до_папки_Products_внутри_архива 
           --identifier "com.example.sadcat" 
           --version "1.0.0" 
           --install-location "/" 
           --sign "Developer ID Installer: Rinat Abidullin (ABCD123456)" 
           build/SadCat-1.0.0.pkg

Два пояснения по опциям команды:

  • --sign - название вашего Developer ID Installer сертификата (можно посмотреть, выполнив в терминале команду % security find-identity -p basic -v)

  • --root - путь до корневой папки, внутри которой находится дерево каталогов. Это может быть папка Products внутри архива (Product -> Archive) или путь до папки, в которую Xcode билдит бинарник при выполнении команды xcodebuild install (расположение папки для второго варианта можно задать в Build Settings в параметре Installation Build Products Location, но, похоже, Xcode 12.5 не воспринимает значение этого параметра)

Заверяем pkg-установщик

У Xcode есть утилита командной строки xcrun altool, которую можно использовать для загрузки pkg-установщика на сервер Apple и последующего заверения:

% xcrun altool --notarize-app 
             --primary-bundle-id "com.example.sadcat" 
             --username "username@example.com" 
             --password "@keychain:Developer-altool" 
             --asc-provider "ABCD123456" 
             --file "SadCat-1.0.0.pkg"

где:

  • --username - электронная почта вашего аккаунта разработчика

  • --password - пароль в связке ключей, который мы недавно создали для Application Specific Password

  • --asc-provider - это Team ID (десять символов). Если вы являетесь членом только одной команды, вам необязательно указывать его.

После успешного выполнения вышеприведенной команды, нам вернется RequestUUID (не потеряйте его). Сервер Apple продолжит процесс заверения pkg-установщика. Чтобы узнать статус заверения, нужно выполнять периодически команду:

% xcrun altool --notarization-info "Your-Request-UUID" 
             --username "username@example.com" 
             --password "@keychain:Developer-altool"

Также статус заверения придет вам на почту.

Если заверение прошло успешно, ваш pkg-установщик смогут использовать любые пользователи, и при этом они не будут больше видеть сообщения системы безопасности macOS, что данное приложение от неустановленного разработчика. Но в текущем виде при запуске pkg-установщика обязательно требуется соединение до серверов заверения Apple, т.е. mac должен быть подключен к интернету.

Чтобы pkg-установщик можно было использовать и на offline-машинах, нужно сделать еще один шаг - прикрепить notarization ticket к pkg-файлу:

% xcrun stapler staple build/SadCat-1.0.0.pkg

Автоматизируем действия (bash)

Скрипт я взял здесь. Он немного отличается от приведенного ниже, так как возможно Xcode 12.5 не учитывает параметр Installation Build Products Location в Build Settings (или проблема в моих кривых руках). Отличие в том, что в моем варианте используется путь до папки Products внутри собранного архива из Xcode:

# put your dev account information into these variables

# the email address of your developer account
dev_account="username@example.com"

# the name of your Developer ID installer certificate
signature="Developer ID Installer: Rinat Abidullin (ABCD123456)"

# the 10-digit team id
dev_team="ABCD123456"

# the label of the keychain item which contains an app-specific password
dev_keychain_label="Developer-altool"


# put your project's information into these variables
version="1.0.0"
identifier="com.example.sadcat"
productname="SadCat"


# code starts here

projectdir=$(dirname $0)

builddir="$projectdir/build"
pkgroot="/Users/rinatab/Library/Developer/Xcode/Archives/2021-08-08/FirebaseDashboardReport 08.08.2021, 19.58.xcarchive/Products/"


# functions
requeststatus() { # $1: requestUUID
    requestUUID=${1?:"need a request UUID"}
    req_status=$(xcrun altool --notarization-info "$requestUUID" 
                              --username "$dev_account" 
                              --password "@keychain:$dev_keychain_label" 2>&1 
                 | awk -F ': ' '/Status:/ { print $2; }' )
    echo "$req_status"
}

notarizefile() { # $1: path to file to notarize, $2: identifier
    filepath=${1:?"need a filepath"}
    identifier=${2:?"need an identifier"}
    
    # upload file
    echo "## uploading $filepath for notarization"
    requestUUID=$(xcrun altool --notarize-app 
                               --primary-bundle-id "$identifier" 
                               --username "$dev_account" 
                               --password "@keychain:$dev_keychain_label" 
                               --asc-provider "$dev_team" 
                               --file "$filepath" 2>&1 
                  | awk '/RequestUUID/ { print $NF; }')
                               
    echo "Notarization RequestUUID: $requestUUID"
    
    if [[ $requestUUID == "" ]]; then 
        echo "could not upload for notarization"
        exit 1
    fi
        
    # wait for status to be not "in progress" any more
    request_status="in progress"
    while [[ "$request_status" == "in progress" ]]; do
        echo -n "waiting... "
        sleep 10
        request_status=$(requeststatus "$requestUUID")
        echo "$request_status"
    done
    
    # print status information
    xcrun altool --notarization-info "$requestUUID" 
                 --username "$dev_account" 
                 --password "@keychain:$dev_keychain_label"
    echo 
    
    if [[ $request_status != "success" ]]; then
        echo "## could not notarize $filepath"
        exit 1
    fi
}

# check if pkgroot exists where we expect it
if [[ ! -d $pkgroot ]]; then
    echo "couldn't find pkgroot $pkgroot"
    exit 1
fi

## build the pkg

pkgpath="$builddir/$productname-$version.pkg"

echo "## building pkg: $pkgpath"

pkgbuild --root "$pkgroot" 
         --version "$version" 
         --identifier "$identifier" 
         --sign "$signature" 
         "$pkgpath"

# upload for notarization
notarizefile "$pkgpath" "$identifier"

# staple result
echo "## Stapling $pkgpath"
xcrun stapler staple "$pkgpath"

echo '## Done!'

# show the pkg in Finder
open -R "$pkgpath"

exit 0

Автоматизируем действия (swift)

Можно автоматизировать действия не на bash-скрипте, а на swift. Для этого нужно создать либо консольную утилиту в Xcode, либо с помощью исполняемого Swift Package Manager. Покажу основные моменты второго способа. Во-первых, нужно создать папку (PkgAndNotarize для примера) для проекта (SPM создаст таргет с таким же именем, как и у папки), переместиться в нее в терминале через команду cd и создать исполняемый SPM:

% swift package init --type executable

В консоли вы увидите:

Creating executable package: PkgAndNotarize
Creating Package.swift
Creating README.md
Creating .gitignore
Creating Sources/
Creating Sources/PkgAndNotarize/main.swift
Creating Tests/
Creating Tests/PkgAndNotarizeTests/
Creating Tests/PkgAndNotarizeTests/PkgAndNotarizeTests.swift

Уже сейчас можно сбилдить и запустить сгенерированный проект:

% swift build
% swift run
Hello, world!

Нам также понадобится библиотека ArgumentParser (которую мы ранее уже использовали). Добавить зависимость нужно в файле Package.swift:

import PackageDescription

let package = Package(
    name: "PkgAndNotarize",
    platforms: [
            .macOS(.v10_13)
        ],
    products: [
        .executable(name: "swiftpkg", targets: ["PkgAndNotarize"])
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser", from: "0.4.4"),
    ],
    targets: [
        .target(
            name: "PkgAndNotarize",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
            ]),
        .testTarget(
            name: "PkgAndNotarizeTests",
            dependencies: ["PkgAndNotarize"]),
    ]
)

Далее в main.swift создаем команду, которая будет принимать одну опцию - путь до архива (.xcarchive):

struct PkgAndNotarize: ParsableCommand {
    static let configuration = CommandConfiguration()
    
    @Option(name: .long, parsing: .next, help: "Path to .xcarchive file")
    var archive: String
    
    func run() throws {
        ...
   	}
}

PkgAndNotarize.main()

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

account => username@example.com
developerIDInstaller => Developer ID Installer: Rinat Abidullin (ABCD123456)
teamID => ABCD123456
keychainLabel => Developer-altool
identifier => com.example.sadcat
pkgName => SadCat
pkgVersion => 1.0.1

В функции run() реализуем ту же логику, которая была в bash-скрипте (ну и дополнительно парсер файла с конфигурацией). Я не буду полностью показывать код, только приведу функцию, которая позволяет выполнять консольные команды:

// Wrapper function for shell commands
// Must provide full path to executable
func shell(launchPath: String, _ arguments: [String] = []) throws -> String? {
    let task = Process()
    task.executableURL = URL(fileURLWithPath: launchPath)
    task.arguments = arguments
    
    let pipe = Pipe()
    task.standardOutput = pipe
    task.standardError = pipe
    
    try task.run()
    
    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8)
    
    task.waitUntilExit()
    
    if task.terminationStatus != 0 {
        throw ShellError.with(info: output ?? "Unknown error")
    }
    
    return output
}

Стоит обратить внимание, что launchPath - это полный путь до консольной команды, например /usr/bin/pkgbuild. Полный путь можно посмотреть в терминале, выполнив type для интересующей команды, например:

% type pkgbuild
pkgbuild is /usr/bin/pkgbuild

Функция shell позволяет консольную команду, которая в терминале выглядит, к примеру, как:

% pkgbuild --root путь_до_папки_Products_внутри_архива 
           --identifier "com.example.sadcat" 
           --version "1.0.0" 
           --install-location "/" 
           --sign "Developer ID Installer: Rinat Abidullin (ABCD123456)" 
           build/SadCat-1.0.0.pkg

в swift записать, как:

let output = try shell(
    launchPath: "/usr/bin/pkgbuild",
    [
        "--root",
        productsPathInArchive,
        "--identifier",
        config.identifier,
        "--version",
        config.pkgVersion,
        "--sign",
        config.developerIDInstaller,
        pkgFile
    ]
)

После реализации всех команд на swift, можно собрать релизную версию утилиты для автоматизации (билд будет находиться в папке .build):

% swift build --configuration release

И далее переместить скомпилированную утилиту в /usr/local/bin/.

Все! Можно воспользоваться только что собранной утилитой и заверить pkg-инсталлятор:

% swiftpkg --archive /Users/rinatab/Library/Developer/Xcode/Archives/2021-08-14/SadCat 14.08.2021, 18.39.xcarchive
Creating pkg...
Sending pkg for notarization...
Notarization RequestUUID = a9b3d582-2cf6-56b9-9c7e-076d5ce256bf
Waiting new status...
in progress
Waiting new status...
in progress
Waiting new status...
in progress
Waiting new status...
in progress
Waiting new status...
in progress
Waiting new status...
success
Stapling SadCat-1.0.1.pkg
Done!
Распространение консольных приложений macOS до конечных пользователей - 17

Путь 3 - Homebrew

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

Использованные материалы

Автор: Ринат

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js