Avalonia UI решает задачу кроссплатформенной разработки. Правильно написанный код без изменений работает под Windows, Linux и macOS. Сложности начинаются на следующем этапе - при подготовке приложения к распространению. Под Windows и macOS есть универсальные форматы инсталлеров, которые работают на всех версиях этих ОС. А вот с Linux ситуация иная. Экосистема Linux фрагментирована. Применяется несколько разных форматов упаковки приложений: deb, rpm. К этому добавляется проблема версий системных библиотек: сборка, работающая на одном дистрибутиве, может не запуститься на другом из-за иной версии glibc или отсутствия необходимой .so-библиотеки. В результате один и тот же бинарник Avalonia-приложения требуется упаковывать несколько раз, разными инструментами, с разными конфигурациями сборки, и каждую такую сборку затем отдельно тестировать, поскольку успешная сборка пакета не гарантирует его работоспособность у конечного пользователя.

Сегодня мы рассмотрим одно из решений этой проблемы - Flatpak. Поддержка Flatpak официально заявлена и документирована у крупных российских дистрибутивов:

  • Astra Linux (входит в базовый репозиторий начиная с версии 1.7)

  • РЕД ОС (отдельная статья в официальной базе знаний)

  • Альт Линукс

Поэтому для разработок, ориентированных на российский рынок, Flatpak - вполне подходящий вариант.

Flatpak версия “Delta Design Домашняя”

У нас недавно был релиз хоббийной версии Delta Design. Мы писали о нём в этой статье. Линукс версия “Delta Design Домашняя” получила унифицированный Flatpak инсталлер. Инсталлер в теории может работать на любой Linux-системе, которая поддерживает Flatpak, но мы в системных требованиях сейчас указываем только те дистрибутивы, на которых у нас запускаются автоматизированные тесты.

Как это устроено внутри

Чтобы не пересказывать документацию по Flatpak, рассмотрим только самые интересные места и проблемы, с которыми мы столкнулись. Если где-то будет непонятно - приходите в комментарии.

DeltaDesignHome представляет собой Avalonia-приложение, которое публикуется в CI в заданную папку.

dotnet publish DeltaDesignHome.Setup.slnf  -bl -c Release --use-current-runtime --self-contained -o bin/PublishProduction

Далее эти бинарные файлы пакуются с помощью утилиты flatpak-builder. Рассмотрим файл манифеста для упаковки нашего приложения.

app-id: ru.eremex.deltadesignhome
runtime: org.gnome.Platform
runtime-version: '49'
sdk: org.gnome.Sdk
command: delta-design-home-wrapper

app-id - уникальный идентификатор приложения в обратной нотации домена, по нему Flatpak различает пакеты, хранит для них отдельные каталоги с данными и настройками. runtime - это базовый образ, набор системных библиотек фиксированной версии, который Flatpak подкладывает приложению вместо того, чтобы полагаться на то, что установлено в системе. Он нужен для того, чтобы GUI-приложению было на чём работать: GTK или его аналоги, графический стек, базовые зависимости рабочего стола - всё то, что у десктопного приложения почти всегда есть под капотом, даже если оно написано на Avalonia и сама Avalonia это не использует напрямую. org.gnome.Platform - один из самых ходовых runtime’ов на Flathub, есть ещё org.kde.Platform и более минимальный org.freedesktop.Platform; на практике для конкретного приложения часто можно взять runtime, который уже использует похожее по типу приложение, и не подбирать состав зависимостей с нуля. sdk - то же самое, но с инструментами для сборки, нужен только на этапе flatpak-builder и в финальный пакет не попадает.

command - какой исполняемый файл запускать при старте приложения. В нашем случае это не сам бинарник DeltaDesignHome, а обёртка delta-design-home-wrapper - её назначение рассматривается далее.

finish-args: что разрешено снаружи песочницы

Это самая важная с точки зрения безопасности часть манифеста - по умолчанию Flatpak-приложение не видит почти ничего, кроме себя самого. Всё, что ему нужно от системы, нужно прописать явно.

В этом манифесте права разбиваются на четыре смысловые группы.

Системные ресурсы:

- --share=ipc
- --share=network
- --socket=pulseaudio
- --socket=x11
- --device=all

--share=network - выход в интернет. --socket=x11 - отрисовка окна на экране (без этого графическое приложение просто не сможет показать UI). --socket=pulseaudio - звук. --device=all - доступ ко всем устройствам, в том числе GPU; для CAD-системы с 3D-отрисовкой без него не заведётся аппаратное ускорение.

Здесь следует учитывать особенность, которая на практике приводит к проблемам при запуске: на момент написания статьи Avalonia-приложения используют только X11 (нативной поддержки протокола Wayland у фреймворка нет), поэтому достаточно одного --socket=x11 - в манифесте DeltaDesignHome указан именно он. Если же приложение поддерживает нативный Wayland и требуется доступ к обоим бэкендам, указывать --socket=x11 и --socket=wayland одновременно как два равноправных сокета не следует - это создаёт конфликт. Для такого случая в Flatpak предусмотрен отдельный флаг --socket=fallback-x11, который даёт доступ к X11 только если Wayland недоступен, и используется в паре с --socket=wayland. Корректная комбинация для приложения с нативной поддержкой обоих бэкендов выглядит так:

- --socket=fallback-x11
- --socket=wayland

а не --socket=x11 плюс --socket=wayland напрямую.

Интеграция с рабочим столом:

- --talk-name=org.freedesktop.Notifications
- --talk-name=org.gnome.Mutter.IdleMonitor
- --talk-name=org.kde.StatusNotifierWatcher
- --talk-name=com.canonical.AppMenu.Registrar

Notifications - системные уведомления, StatusNotifierWatcher - иконка в трее (KDE-вариант), AppMenu.Registrar - глобальное меню в духе Unity/Ubuntu. Здесь перечислены сервисы и для GNOME, и для KDE одновременно - приложение не знает заранее, в каком окружении его запустят, поэтому запрашивает права на оба варианта сразу.

Доступ к файлам:

- --filesystem=home
- --talk-name=org.freedesktop.portal.FileChooser

Этот пункт требует отдельного рассмотрения. --filesystem=home - это разрешение на чтение и запись всего домашнего каталога пользователя, не только конкретных подпапок. Это широкое право, и в общем случае хорошей практикой считается запрашивать более узкие права, такие как --filesystem=xdg-documents. Но для CAD-приложения, где пользователь может открыть проект из произвольного места на диске, а не только из ~/Documents, это осознанный компромисс: иначе пользователю придётся каждый раз проходить через диалог выбора файла при открытии проекта вне стандартных папок.

Постоянное хранение данных:

- --persist=.local/share/ru.eremex.deltadesignhome/data
- --persist=.config/ru.eremex.deltadesignhome
- --filesystem=~/.local/share/ru.eremex.deltadesignhome/data:rw

--persist - без этого атрибута данные будут считаться частью песочницы и не будут сохраняться при переустановке приложения.

Модули: из чего собирается /app

Далее идёт modules - список того, что попадает внутрь финального пакета, в порядке выполнения. У каждого модуля свой buildsystem (тут везде simple - просто список shell-команд, никакой автоматической сборки из исходников) и свой набор sources.

custom-fonts - копирует шрифты в /app/share/fonts.

DeltaDesignHome - собственно само приложение. Берёт опубликованную (dotnet publish) сборку из ./bin/PublishProduction, плюс .desktop-файл, иконку, appdata.xml и тот самый delta-design-home-wrapper. Здесь же создаются стандартные каталоги Flatpak-пакета:

- mkdir -p /app/share/applications
- mkdir -p /app/share/icons/hicolor/scalable

.desktop-файл и иконка кладутся по стандартным для Linux путям (applications, icons/hicolor) - именно по этой структуре система понимает, что появилось новое приложение, и рисует для него ярлык в меню.

Отдельного внимания заслуживает delta-design-home-wrapper - command в начале манифеста указывает именно на него, а не на сам бинарник DeltaDesignHome. Перед запуском основного UI требуется выполнить некоторую проверку или подготовку. Назначение этого скрипта рассматривается в отдельном разделе, посвящённом wrapper-скрипту. Сам .NET-бинарник Avalonia-приложения практически никогда не запускают напрямую как command в продакшен-манифестах - обёртка предоставляет точку, через которую можно изменить процесс старта, не затрагивая код самого приложения.

Автоматизированное тестирование инсталляции

Успешная сборка Flatpak-пакета не означает завершение работы. Манифест может скомпилироваться без единой ошибки и при этом приводить к неработающему приложению у пользователя: может не хватить права на нужный D-Bus интерфейс, persist может не подхватить каталог с данными после обновления, либо приложение может завершаться сразу после старта в чистом окружении, где отсутствуют необходимые шрифты. Таким образом, успешная сборка пакета не гарантирует его работоспособности - собранный артефакт необходимо проверять.

Для этого используются обычные MSTest-тесты - тот же фреймворк, что и для остальной кодовой базы на .NET. Идея состоит в следующем: тестовый набор запускается не на изолированном коде, а на уже установленном Flatpak-пакете, так что проверяется именно то, что в итоге получит пользователь - со всеми правами из finish-args, с тем же .NET runtime, что работает внутри песочницы, и с той же файловой структурой.

Процесс состоит из трёх шагов.

Сборка тестового проекта и копирование его внутрь установленного пакета. Flatpak-приложение после установки располагается в /var/lib/flatpak/app/<app-id>/current/active/files - это содержимое того самого /app, который собирался модулями в манифесте. Туда же, рядом со штатным бинарником, копируется собранный MSTest-проект:

cp artifacts/bin/DeltaDesign.E2E.InstallDDHome.Tests/release/DeltaDesign.E2E.InstallDDHome.Tests \
   /var/lib/flatpak/app/ru.eremex.deltadesignhome/current/active/files/bin/

Отдельной упаковки теста в манифест не требуется - он оказывается там же, где и всё остальное содержимое пакета, и использует тот же .NET runtime, те же зависимости. Отметим, что Flatpak-пакет может быть установлен на уровне системы или в пользовательскую папку. Это влияет на путь к бинарным файлам Flatpak. В данном примере рассматривается первый вариант.

Запуск тестов через подмену команды, а не самого приложения. У flatpak run есть параметр --command, который переопределяет точку входа, заданную в манифесте полем command. Вместо запуска delta-design-home-wrapper напрямую вызывается тестовый бинарник:

flatpak run --command=DeltaDesign.E2E.InstallDDHome.Tests \
  ru.eremex.deltadesignhome \
  $TEST_ARGS --results-directory=$CI_PROJECT_DIR

Ключевой момент здесь - тесты выполняются внутри той же песочницы, что и сама программа. Если в finish-args чего-то не хватает (например, не указан --persist для каталога с данными), это будет выявлено уже на данном этапе, а не после релиза пакета. $TEST_ARGS - стандартные параметры MSTest (фильтры, конфигурация запуска), а --results-directory=$CI_PROJECT_DIR направляет отчёт о тестах в каталог CI-джобы, откуда его впоследствии можно прикрепить как артефакт пайплайна.

Получение отчёта в привычном формате. Поскольку используется MSTest, результаты выходят в том же формате (.trx и так далее), что и обычные unit-тесты проекта - отдельный парсер вывода под Flatpak не требуется, CI читает их так же, как любой другой тестовый прогон.

Практическое преимущество такого подхода: тест видит то же окружение, в котором будет работать приложение у пользователя. Если приложению не хватает прав на сеть, на конкретный D-Bus сервис или на каталог с данными, тест выявит это так же, как столкнулся бы с этим реальный пользователь - но на этапе CI, до релиза.

Адаптация Flatpak к российским реалиям: установка без интернета

Flatpak по умолчанию рассчитан на постоянное подключение к удалённому репозиторию, такому как Flathub. Пакет и его рантаймы скачиваются оттуда при установке. Однако это не означает, что Flatpak в принципе нельзя установить на изолированную машину.

flatpak create-usb - эта команда копирует приложение вместе со всеми его зависимостями (включая нужный runtime) в заданный каталог.

flatpak create-usb /media/user/FLATPAKS ru.eremex.deltadesignhome

В результате в заданной папке появляется полноценный мини-репозиторий - внутри него каталог .ostree/repo, такой же по структуре, как настоящий удалённый репозиторий.

Установка на машине без интернета выполняется следующей командой:

flatpak install --sideload-repo=/media/user/FLATPAKS/.ostree/repo flathub ru.eremex.deltadesignhome

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

  • Remote должен быть настроен заранее. --sideload-repo указывает, откуда брать данные пакета, но сам факт наличия remote с именем flathub (или любым другим) на офлайн-машине должен быть обеспечен ещё до этой команды - create-usb переносит содержимое пакетов, а не сам источник.

  • Репозиторий должен быть подписан. Для офлайн-установки нужны GPG-подписи у репозитория и настроенный collection ID - и на стороне сервера, откуда исходно ставилось приложение, и в локальной конфигурации remote’а на офлайн-машине. Без этого Flatpak откажется доверять данным с носителя.

Таким образом, ограничение в виде закрытой сети не является препятствием для использования Flatpak. Процесс установки версии “Delta Design Домашняя” без интернета описан в этой статье.

Первый запуск вместо мастера установки

Ещё одна особенность, с которой мы столкнулись, - отсутствие мастера установки. Если приложение ранее распространялось через классический Windows-инсталлятор, пользователи привыкли к графической оболочке, в которой показывается EULA и настраиваются некоторые параметры установки. У flatpak install отсутствует какой-либо интерактивный мастер настройки. Команда либо загружает пакет и проверяет его подпись, либо разворачивает уже скачанные данные в /var/lib/flatpak - без диалогов, без мастера настройки, без возможности показать пользователю что-либо и запросить согласие непосредственно в процессе установки.

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

  1. При старте приложение проверяет, есть ли в его каталоге настроек ($XDG_CONFIG_HOME, то есть ~/.var/app/<app-id>/config) маркер-файл или запись в конфигурации, означающая, что первичная настройка уже выполнена.

  2. Если маркер отсутствует - это «первый запуск». Приложение отображает собственный диалог: с EULA, с настройкой подключения к БД, с любым другим содержимым, ранее реализованным мастером инсталлятора.

  3. После того как пользователь прошёл этот шаг (принял лицензию, указал параметры), приложение записывает маркер в тот же $XDG_CONFIG_HOME и более не показывает этот экран.

Преимущества такого подхода: маркер располагается в персистентном каталоге пользователя, а не в /app, и поэтому сохраняется при обновлении пакета - пользователь не увидит EULA повторно после каждого flatpak update, если только не требуется показывать его снова при смене версии лицензии. Поскольку данная логика реализована в коде приложения, а не в отдельном скрипте установщика, она работает одинаково независимо от способа установки пакета - как через Flathub, так и офлайн через create-usb.

Рассмотрим подробнее delta-design-home-wrapper из манифеста - этот файл упоминался ранее при разборе поля command. Именно этот скрипт реализует логику первого запуска, описанную выше.

Принцип работы заключается в следующем: command в манифесте, а значит и Exec в .desktop-файле, указывает не на основной .NET-бинарник DeltaDesignHome, а на промежуточный bash-скрипт. При запуске приложения пользователем выполняется именно этот скрипт, который определяет дальнейшие действия.

Логика внутри минимальная:

#!/bin/sh

CONFIG_MARKER="$XDG_CONFIG_HOME/ru.eremex.deltadesignhome/initialized"

if [ ! -f "$CONFIG_MARKER" ]; then
  # маркера нет - это первый запуск
  exec /app/bin/DeltaDesignHome firstrun
else
  # обычный запуск
  exec /app/bin/DeltaDesignHome
fi

Проверка основана на наличии файла-маркера в $XDG_CONFIG_HOME - каталоге настроек, который физически располагается в ~/.var/app/ru.eremex.deltadesignhome/config и сохраняется при обновлениях пакета. Если маркер отсутствует, скрипт запускает основной исполняемый файл с дополнительным аргументом firstrun, и приложение, получив этот параметр при старте, отображает экран первичной настройки (EULA, параметры подключения к базе и так далее), а после прохождения пользователем этого шага создаёт файл-маркер. При следующем запуске обёртка обнаруживает наличие маркера и передаёт управление приложению без дополнительных параметров.

Переменные окружения

Если приложение использует переменные окружения хоста, следует учитывать, что внутри песочницы окружение отличается от окружения вне её. Часть переменных Flatpak действительно передаёт в приложение по умолчанию, если не указан флаг --clear-env, однако полагаться на это не следует - набор переданных переменных зависит от конкретной системы и версии Flatpak, и это не тот контракт, на который стоит ориентироваться в продакшен-приложении.

Если приложению для работы нужна конкретная переменная окружения - будь то путь к лицензионному серверу, флаг конфигурации, который раньше выставлялся в /etc/environment, или что-то специфичное для рендеринга - её нужно объявить явно в finish-args через --env:

- --env=VARIABLE_NAME=значение

Если переменных много или их значения нежелательно задавать непосредственно в манифесте (например, они различаются для разных сборок), у flatpak run есть runtime-альтернатива - flatpak override --env=VAR=VALUE <app-id>, которая сохраняет переопределение постоянно для конкретного пользователя без пересборки пакета. Однако для переменных, без которых приложение не может корректно запуститься, правильное место - именно finish-args в манифесте: тогда переменная будет установлена у любого пользователя сразу после установки, а не только у тех, кто отдельно выполнил override.

Заключение

Один унифицированный Flatpak-инсталлер обеспечивает установку пакета одинаковым образом на Ubuntu, Fedora, openSUSE а так же на основных российских дистрибутивах. Для Avalonia-приложений этот подход особенно удобен, поскольку .NET-рантайм и необходимые нативные библиотеки упаковываются вместе с приложением и не зависят от того, что предустановлено в целевой системе.

У нас были опасения: не приведёт ли изоляция в песочнице к снижению производительности по сравнению с обычной установкой. На практике для рассматриваемого приложения заметного снижения производительности зафиксировано не было - ни при запуске, ни при работе. На более тяжёлых I/O-сценариях разница, по всей видимости, может проявляться сильнее, однако как общий аргумент против использования Flatpak довод о снижении производительности в данном случае не подтвердился.

Flatpak не подойдёт, если приложение должно работать в виде демона - классические deb/rpm будут удобнее. Также, если требуется компактный размер дистрибутива, лучше остаться на deb/rpm.

Во всех остальных случаях Flatpak существенно снижает издержки, связанные с фрагментацией Linux-экосистемы, и позволяет избежать поддержки отдельного пайплайна под каждый пакетный менеджер. Изолированная от интернета сеть также не является препятствием.