Добавляем поддержку Flatpak в Compose Desktop
https://t.me/KotlinSenior
Те, кто делали мультиплатформенное приложение с помощью Compose Multiplatform, наверное уже сталкивались с тем, в как публиковать приложение. Для Linux на текущий момент доступны следующие форматы: Deb - "нативные" пакеты для Debian-подобных дистрибутивов; Rpm - такие же пакеты для Fedora, RHEL; AppImage - portable приложения(одним файлом). Недостаток первых двух - заточенность только под одну платформу(Debian и Fedora соответственно), второго - отсутствие пакетного менеджера в абсолютном большинстве дистрибутивов. Негодуя с этого, я решил внедрить compose-приложение в Flatpak - пакетный менеджер для sandboxed приложений. Sandboxed apps - приложения, которые по умолчанию не имеют доступа к файлам пользователя и другим настройкам. Flatpak дает уверенность, что та или иная функция/бинарник присутствуют в системе и могут быть использованы. Также с помощью Portals, которые встроены в Flatpak, приложение может безопасно и независимо осуществлять некоторые операции вроде доступа к камере, показа уведомлений и другого. Как вы могли видеть ранее, поддержки Flatpak в Compose Multiplatform нет.
Что нужно установить?
- Непосредственно Flatpak. Как установить можно посмотреть здесь.
- Flatpak-builder - для сборки flatpak-приложений. Обычно устанавливается так же, как и сам пакетный менеджер.
- Установить
org.freedesktop.Sdkиorg.freedesktop.Platformверсии22.08через flatpak.
Как мы собираемся реализовать поддержку Flatpak?
Так как у Compose Desktop из таргетов нет ничего универсальнее AppImage, то будем использовать его. Он собирается с помощью gradle task packageAppImage. Бинарник находится по пути build/compose/binaries/main/app/[appName]/, где [appName] - имя проекта/приложения.
В этой папке следующая структура:
MyApp
├── bin
│ └── MyApp
└── lib
├── app/
├── libapplauncher.so
├── runtime/
└── MyApp.png
В папке bin находится сам бинарник, который мы собираемся запускать. В папке lib находятся "кишки" приложения: app - jar-ники библиотек и ресурсы. libapplauncher.so - волшебный файл, соединяющий все внутренности. runtime - внутренние библиотеки. MyApp.png - иконка приложения(не используется).
Сам бинарник без папки lib/ не запустится!
Добавляем манифест и иконку
Для любого Flatpak-приложения нужен манифест. Аналог в Android-мире - AndroidManifest.xml. Он описывает разрешения и основную информацию о приложении. В Flatpak для этого используется формат описывания json или yaml.
Создадим файл src/desktopMain/resources/flatpak/manifest.yml:
app-id: com.company.myapp
runtime: org.freedesktop.Platform
runtime-version: '22.08'
sdk: org.freedesktop.Sdk
command: /app/bin/MyApp
finish-args:
- --share=network
- --socket=x11
- --socket=fallback-x11
- --device=dri
modules:
- name: myapp
buildsystem: simple
build-commands:
- cp -r bin/ /app/bin/
- cp -r lib/ /app/lib/
- mkdir -p /app/share/applications
- install -D com.company.myapp.desktop /app/share/applications/com.company.myapp.desktop
- mkdir -p /app/share/icons/hicolor/scalable/apps/
- cp -r logo_round_preview.svg /app/share/icons/hicolor/scalable/apps/com.company.myapp.svg
sources:
- type: file
path: logo_round_preview.svg
- type: dir
path: "bin/"
dest: "bin/"
- type: dir
path: "lib/"
dest: "lib/"
- type: file
path: com.company.myapp.desktop
- (1) Id приложения(как applicationId в Android). Обязательный пункт
- (2) Какой runtime нам нужен. Выбираем
org.freedesktop.Platformтак как это стандартный runtime. Обязательный пункт - (3) Версия runtime. Очень желательно использовать наиболее свежую версию. Обязательный пункт
- (4) Sdk для сборки приложения. Обязательный пункт
- (5) Путь к бинарнику приложения. Обязательный пункт
- (6-10) Разрешения для приложения:
--share=network- доступ к интернету.--socket=x11- доступ к оконному менеджеру X11. На данный момент Compose Desktop не поддерживает Wayland. Обязательный пункт--socket=fallback-x11- чтобы нормально запускаться под чистым Wayland(не напрямую). Очень желательно--device=dri- аппаратное ускорение с помощью GPU. Очень желательно- (11-31) Модули для установки(список).
- (12) Название модуля.
- (13) Система сборки.
- (14-20) Команды во время сборки:
- (15-16) Копирование папки bin и lib в соответствующие папки в внутреннее хранилище приложения.
- (17) Создание папки, где будет храниться файл конфигурации иконки.
- (18) Копирование файла конфигурации в внутреннее хранилище. Файл обязательно назвать так [appId].desktop, где [appId] - id приложения.
- (19) Создание папки, где будет храниться сама иконка.
- (20) Копирование иконки в внутреннее хранилище. Обязательно svg. Если хотите загружать в других форматах, смотрите здесь
- (21-31) Ресурсы, которые нужны модулю:
- (22) Тип ресурса. Самые используемые - file и dir
- (23) Путь к файлу. Также, вместо
pathможно вставлятьurlи брать файлы c интернета.
Далее создадим файл конфигурации иконки src/desktopMain/resources/flatpak/icon.desktop. Он нужен, чтобы понимать системе, как показывать и запускать приложение(надо указывать всё):
[Desktop Entry] Encoding=UTF-8 Version=1.0 Type=Application Terminal=false Exec=/app/bin/MyApp Name=MyApp Icon=com.company.myapp
- Указание, что это файл конфигурации иконки.
- Кодировка(UTF-8, стандартная).
- Версия спецификации файла конфигурации.
- Тип приложения. В нашем случае - Application.
- Запускать ли приложение в терминале. Если указать
true, то при запуске приложения запустится и терминал. - Путь к бинарнику, который мы указывали в манифесте.
- Имя, которое будет видно в лаунчере.
- Иконка приложения в виде id приложения(именно поэтому мы указывали app id, когда копировали иконку)
Дальше, если хотим svg, надо будет добавить иконку в src/desktopMain/resources/. Если хотим другие форматы, смотрим здесь.
Конфигурируем Gradle
В первую очередь, надо добавить поддержку AppImage в наш проект. Делается это в build.gradle.kts:
compose.desktop {
//...
application {
//...
nativeDistributions {
//...
targetFormats(
TargetFormat.AppImage,
// Другие форматы
)
}
}
}
Также, добавим новый task в тот же файл:
val appId = "com.company.myapp"
tasks.register("packageFlatpak") {
dependsOn("packageAppImage")
doLast {
delete {
delete("$buildDir/flatpak/bin/")
delete("$buildDir/flatpak/lib/")
}
copy {
from("$buildDir/compose/binaries/main/app/MyApp/")
into("$buildDir/flatpak/")
exclude("$buildDir/compose/binaries/main/app/MyApp/lib/runtime/legal")
}
copy {
from("$rootDir/src/desktopMain/resources/logo_round_preview.svg")
into("$buildDir/flatpak/")
}
copy {
from("$rootDir/src/desktopMain/resources/logo_round.svg")
into("$buildDir/flatpak/")
}
copy {
from("$rootDir/src/desktopMain/resources/flatpak/manifest.yml")
into("$buildDir/flatpak/")
rename {
"$appId.yml"
}
}
copy {
from("$rootDir/src/desktopMain/resources/flatpak/icon.desktop")
into("$buildDir/flatpak/")
rename {
"$appId.desktop"
}
}
exec {
workingDir("$buildDir/flatpak")
commandLine("flatpak-builder --install --user --force-clean --state-dir=build/flatpak-builder --repo=build/flatpak-repo build/flatpak-target $appId.yml".split(" "))
}
}
}
Этот task будет копировать все файлы в build/flatpak, собирать и устанавливать приложение.
Если хотим запускать приложение прямо из ide, то создаем еще один task:
tasks.register("runFlatpak") {
dependsOn("packageFlatpak")
doLast {
exec {
commandLine("flatpak run $appId".split(" "))
}
}
}
После синхронизации градла все эти программы будут должны появиться во вкладке Gradle Tasks -> other:

Если дважды кликнем по нему, то автоматически запустится этот task.
Интеграция с системой
На текущий момент метод isSystemInDarkTheme всегда возвращает false, если запускать приложение в Flatpak. Здесь на помощь приходят Portals. С помощью них мы можем получить информацию о текущей теме устройства. К сожалению, это api появилось совсем недавно, поэтому оно поддерживается только новыми средами рабочего стола. Это делается с помощью этой команды:
gdbus call --session --dest=org.freedesktop.portal.Desktop --object-path=/org/freedesktop/portal/desktop --method=org.freedesktop.portal.Settings.Read org.freedesktop.appearance color-scheme
Команда возвращает темную тему в таком формате:
(<<uint32 1>>,)
Где 1 означает, что темная тема включена. Если она отключена, то вместо 1 будет 0.
Давайте напишем функцию, которая будет читать вывод с терминала:
suspend fun runInShell(command: String): Process {
return ProcessBuilder(*command.split(" ").toTypedArray())
.redirectError(ProcessBuilder.Redirect.INHERIT)
.start().apply {
waitFor(60, TimeUnit.MINUTES)
}
}
suspend fun Process.readString(): String {
var o = ""
val b = BufferedReader(InputStreamReader(inputStream))
var line = ""
while (b?.readLine()?.also { line = it } != null) o += line
return o
}
На вход методу runInShell подается полная команда в виде строки без всяких разделителей.
В модуле commonMain создадим expect Composable-функцию, которая будет возвращать тему в системе:
@Composable expect fun platformIsSystemInDarkTheme(): Boolean
В модуле desktopMain создадим имлементацию этого метода, где будет раз в 0.1 секунду проверять на текущую тему:
private const val command =
"gdbus call --session --dest=org.freedesktop.portal.Desktop --object-path=/org/freedesktop/portal/desktop --method=org.freedesktop.portal.Settings.Read org.freedesktop.appearance color-scheme"
@Composable
actual fun platformIsSystemInDarkTheme(): Boolean {
var darkTheme by remember { mutableStateOf(false) }
LaunchedEffect(true) {
while (true) {
kotlin.runCatching {
val str = runInShell(command).readString()
darkTheme = str[10] == '1'
}
delay(100)
}
}
return darkTheme
}
В фунции, где вы прописываете тему приложения, вызываем метод(один раз!):
@Composable
fun AppTheme(
darkTheme: Boolean = platformIsSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
//...
}
Теперь ваше приложение автоматически подстраивается под системную тему.
Небольшие хитрости
Если у вас не запускается приложение, то попробуйте обновить compose до крайней версии. Зачастую это помогает(особенно в alpha-версиях). Если не поможет, то уберите параметры undecorated и transparent в Composable-окне приложения:
fun main() =
application {
Window(
onCloseRequest = {
exitApplication()
},
title = "MyApp",
undecorated = true, // закомментировать, если не запускается
transparent = true // закомментировать, если не запускается
) {
// content
}
}
Заключение
Если хотите подробнее углубиться в тему Flatpak, то читайте документацию.
Исходники вы можете увидеть здесь.