Рекомендую обратиться к статье в википедии, если слышите о приложении Wallet(Passbook) впервые.

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

Обычно добавление пассов в приложение реализуют через мобильное приложение, в котором есть функционал генерации пассов, либо через отправку на почту.

Я же расскажу о более редком виде распространения пассов. Предположим, что есть задача дать возможность пользователям iPhone добавить себе в приложение Wallet(Passbook) сгенерированный пасс с определенными данными по нажатию кнопки на веб-сайте. Не буду описывать логику на клиенте. В примере будет код обычного скрипта, при вызове которого будет происходить генерация пасса.

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

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

Писать свой генератор пассов - занятие, на которое у рядового разработчика просто нет времени. Правильным решением будет воспользоваться готовым решением, если таковое имеется. Я рекомендую eo/passbook, отличный php-пакет, который поможет решить задачу быстро и эффективно.

Устанавливаем eo/passbook

$ composer require "eo/passbook" "1.*"

Подключаем автозагрузку Composer.

require 'vendor/autoload.php';

Согласно документации существует несколько типов пассов. Различия между этими типами заключаются в дизайне и наборе полей, доступных на лицевой стороне пасса. Допустим, что типом пасса будет “Билет”.

// каждый пасс должен иметь уникальный идентификатор
$date = new \DateTime();
$uniqueId = 'best_pass_ever_' . $date->format('Y_m_d_h_i_s');
$pass = new EventTicket($uniqueId, 'Wallet example');
// устанавливаем цвет бэкграунда
$pass->setBackgroundColor('rgb(255, 255, 255)');
// устанавливаем цвет шрифта
$pass->setForegroundColor('rgb(0, 0, 0)');
// устанавливаем цвет шрифта лейблов
$pass->setLabelColor('rgb(0, 0, 0)');

Далее создаем структуру полей пасса. Начнем с поля заголовка и вторичного поля.

$structure = new Structure();

// поле заголовка
// конструктор поля должен состоять из уникального ключа и значения поля
$headerField = new Field('header', 'Поле заголовка');
// также можно добавить лейбл
$headerField->setLabel('Лейбл поля заголовка');
$structure->addHeaderField($headerField);

// вторичное поле
$secondaryField = new Field('secondary', 'Вторичное поле');
$secondaryField->setLabel('Лейбл вторичного поля');
$structure->addSecondaryField($secondaryField);

Продолжим с дополнительным полем и полями задней стороны пасса.

// дополнительное поле
$auxiliaryField = new Field('auxiliary', 'Дополнительное поле');
$auxiliaryField->setLabel('Лейбл дополнительного поля');
$structure->addAuxiliaryField($auxiliaryField);

// первое поле задней стороны пасса
$backField = new Field('back_field_one', '+7 (999) 999-99-99');
$backField->setLabel('Пример с номером телефона:');
$structure->addBackField($backField);

// второе поле задней стороны пасса
$backField = new Field('back_field_two', '<a href="https://google.com">Ссылка</a>');
$backField->setLabel('Пример ссылки:');
$structure->addBackField($backField);

// можно не ограничиваться одним или двумя!

Пришло время добавить изображения. Важно отметить, что изображения должны быть разных размеров для обычных и ретина дисплеев.

// логотип
$logoImage = new Image('/full/path/to/logo/', 'logo');
$pass->addImage($logoImage);
$logoImage = new Image('/full/path/to/retina/logo/', 'logo');
$logoImage->setIsRetina(true);
$pass->addImage($logoImage);

// иконка, видна на экране блокировки
$iconImage = new Image('/full/path/to/icon/', 'icon');
$pass->addImage($iconImage);
$iconImage = new Image('/full/path/to/retina/icon/', 'icon');
$iconImage->setIsRetina(true);
$pass->addImage($iconImage);

// strip
$stripImage = new Image('/full/path/to/strip/', 'strip');
$pass->addImage($stripImage);
$stripImage = new Image('/full/path/to/retina/strip/', 'strip');
$stripImage->setIsRetina(true);
$pass->addImage($stripImage);

Есть возможность добавить отдельную ссылку на какое-либо приложение в AppStore. Для примера добавим приложение Youtube. Для добавления требуется идентификатор приложения. Узнать его можно из ссылки на приложение.

$pass->addAssociatedStoreIdentifier(544007664);

Добавление bar/qr-кода опционально. Добавим qr-код, ведущий на главную страницу поисковика Google.

$barcode = new Barcode(Barcode::TYPE_QR, 'http://google.com');
$pass->setBarcode($barcode);

Заканчиваем генерацию структуры и создаем пасс. Параметры, передаваемые в конструктор PassFactory в большинстве своем можно узнать из аккаунта разработчика под ios.

$pass->setStructure($structure);

$factory = new PassFactory(
    // Идентификатор типа пасса (bundle id)
    'TYPE_IDENTIFIER',
    // Идентификатор команды разработчика
    'TEAM_ID',
    // Название компании разработчика
    'ORGANIZATION_NAME',
    // Путь до сертификата разработчика
    '/full/path/to/developer/certificate/',
    // Пароль к сертификату разработчика
    'DEVELOPER_CERTIFICATE_PASSWORD',
    // Путь до WWDR сертификата
    '/full/path/to/wwdr/certificate/',
);

$factory->setOverwrite(true);
// Путь до папки, в которую будут сохраняться готовые пассы
$factory->setOutputPath('/path/to/generated/passes/');

$file = $factory->package($pass);

Имеется возможность провалидировать готовый пасс. Воспользуемся этой возможностью.

$validator = new PassValidator();
$validator->validate($pass);
if ($validator->getErrors()) {
    $errorsString = implode(' | ', $validator->getErrors());
    throw new \Exception("Ошибки валидации passbook: $errorsString");
}

Теперь, когда пасс готов, его еще нужно отдать клиенту с определенными заголовками.

$passBookFullPath = $file->getPath() . "/{$uniqueId}.pkpass";

header("Pragma: no-cache");
header("Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0");
header("Content-Type: application/vnd.apple.pkpass");
header('Content-Disposition: attachment; filename="pass.pkpass"');
clearstatcache();
$fileSize = filesize($passBookFullPath);
if ($fileSize) {
    header("Content-Length: " . $fileSize);
}
header('Content-Transfer-Encoding: binary');
if (filemtime($passBookFullPath)) {
    date_default_timezone_set("UTC");
    header(
        'Last-Modified: '
        . date("D, d M Y H:i:s", filemtime($passBookFullPath))
        . ' GMT'
    );
}
flush();
readfile($passBookFullPath);
exit();

Собираем куски кода воедино:

<?php
require 'vendor/autoload.php';

use Passbook\Pass\Barcode;
use Passbook\Pass\Field;
use Passbook\Pass\Image;
use Passbook\Pass\Structure;
use Passbook\PassFactory;
use Passbook\PassValidator;
use Passbook\Type\EventTicket;

// каждый пасс должен иметь уникальный идентификатор
$date = new \DateTime();
$uniqueId = 'best_pass_ever_' . $date->format('Y_m_d_h_i_s');
$pass = new EventTicket($uniqueId, 'Wallet example');
// устанавливаем цвет бэкграунда
$pass->setBackgroundColor('rgb(255, 255, 255)');
// устанавливаем цвет шрифта
$pass->setForegroundColor('rgb(0, 0, 0)');
// устанавливаем цвет шрифта лейблов
$pass->setLabelColor('rgb(0, 0, 0)');

$structure = new Structure();

// поле заголовка
// конструктор поля должен состоять из уникального ключа и значения поля
$headerField = new Field('header', 'Поле заголовка');
// также можно добавить лейбл
$headerField->setLabel('Лейбл поля заголовка');
$structure->addHeaderField($headerField);

// вторичное поле
$secondaryField = new Field('secondary', 'Вторичное поле');
$secondaryField->setLabel('Лейбл вторичного поля');
$structure->addSecondaryField($secondaryField);

// дополнительное поле
$auxiliaryField = new Field('auxiliary', 'Дополнительное поле');
$auxiliaryField->setLabel('Лейбл дополнительного поля');
$structure->addAuxiliaryField($auxiliaryField);

// первое поле задней стороны пасса
$backField = new Field('back_field_one', '+7 (999) 999-99-99');
$backField->setLabel('Пример с номером телефона:');
$structure->addBackField($backField);

// второе поле задней стороны пасса
$backField = new Field('back_field_two', '<a href="https://google.com">Ссылка</a>');
$backField->setLabel('Пример ссылки:');
$structure->addBackField($backField);

// можно не ограничиваться одним или двумя!

// логотип
$logoImage = new Image('/full/path/to/logo/', 'logo');
$pass->addImage($logoImage);
$logoImage = new Image('/full/path/to/retina/logo/', 'logo');
$logoImage->setIsRetina(true);
$pass->addImage($logoImage);

// иконка, видна на экране блокировки
$iconImage = new Image('/full/path/to/icon/', 'icon');
$pass->addImage($iconImage);
$iconImage = new Image('/full/path/to/retina/icon/', 'icon');
$iconImage->setIsRetina(true);
$pass->addImage($iconImage);

// strip
$stripImage = new Image('/full/path/to/strip/', 'strip');
$pass->addImage($stripImage);
$stripImage = new Image('/full/path/to/retina/strip/', 'strip');
$stripImage->setIsRetina(true);
$pass->addImage($stripImage);

$pass->addAssociatedStoreIdentifier(544007664);

$pass->setStructure($structure);

$barcode = new Barcode(Barcode::TYPE_QR, 'http://google.com');
$pass->setBarcode($barcode);

$factory = new PassFactory(
    // Идентификатор типа пасса (bundle id)
    'TYPE_IDENTIFIER',
    // Идентификатор команды разработчика
    'TEAM_ID',
    // Название компании разработчика
    'ORGANIZATION_NAME',
    // Путь до сертификата разработчика
    '/full/path/to/developer/certificate/',
    // Пароль к сертификату разработчика
    'DEVELOPER_CERTIFICATE_PASSWORD',
    // Путь до WWDR сертификата
    '/full/path/to/wwdr/certificate/',
);

$factory->setOverwrite(true);
// Путь до папки, в которую будут сохраняться готовые пассы
$factory->setOutputPath('/path/to/generated/passes/');

$file = $factory->package($pass);

$validator = new PassValidator();
$validator->validate($pass);
if ($validator->getErrors()) {
    $errorsString = implode(' | ', $validator->getErrors());
    throw new \Exception("Ошибки валидации passbook: $errorsString");
}

$passBookFullPath = $file->getPath() . "/{$uniqueId}.pkpass";

header("Pragma: no-cache");
header("Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0");
header("Content-Type: application/vnd.apple.pkpass");
header('Content-Disposition: attachment; filename="pass.pkpass"');
clearstatcache();
$fileSize = filesize($passBookFullPath);
if ($fileSize) {
    header("Content-Length: " . $fileSize);
}
header('Content-Transfer-Encoding: binary');
if (filemtime($passBookFullPath)) {
    date_default_timezone_set("UTC");
    header(
        'Last-Modified: '
        . date("D, d M Y H:i:s", filemtime($passBookFullPath))
        . ' GMT'
    );
}
flush();
readfile($passBookFullPath);
exit();

В результате получим пасс примерно следующего содержания:

wallet example

А вот его задняя сторона:

wallet back example

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

А вот по недостаткам и подводным камням стоит пройтись:

  • Ужасная документация и это не только мое мнение.
  • Слишком много места отводится qr/bar кодам, остальной контент не всегда возможно уместить на лицевой стороне.
  • Ограничения на количество символов для лейблов как на лицевой так и на задней сторонах.
  • Если не указать какое-либо значение для полей на лицевой или задней стороне, то пасс будет считаться невалидным.
  • В целом, очень мало возможностей для кастомизации дизайна.

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

Refactoring to collections или как заменить foreach коллекциями

Этот пост будет целиком и полностью посвящен книге «Refactoring to collections», написанной Adam Wathan. Читать дальше

Подкасты

Опубликовано 20.10.2015