Те, кто делали мультиплатформенное приложение с помощью 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, то читайте документацию.
Исходники вы можете увидеть здесь.
Автор чукча, поэтому если увидели ошибки - пишите.
Beholder
Ооочень плохой пример функции-расширения. "Строка вообще" не обладает свойством "запускаться в шелле".
Достаточно было бы просто
fun runInShell(command: String): Process
Renattele Автор
Спасибо, исправил.