Те, кто делали мультиплатформенное приложение с помощью 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) Разрешения для приложения:

    1. --share=network - доступ к интернету.

    2. --socket=x11 - доступ к оконному менеджеру X11. На данный момент Compose Desktop не поддерживает Wayland. Обязательный пункт

    3. --socket=fallback-x11 - чтобы нормально запускаться под чистым Wayland(не напрямую). Очень желательно

    4. --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
  1. Указание, что это файл конфигурации иконки.

  2. Кодировка(UTF-8, стандартная).

  3. Версия спецификации файла конфигурации.

  4. Тип приложения. В нашем случае - Application.

  5. Запускать ли приложение в терминале. Если указать true, то при запуске приложения запустится и терминал.

  6. Путь к бинарнику, который мы указывали в манифесте.

  7. Имя, которое будет видно в лаунчере.

  8. Иконка приложения в виде 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, то читайте документацию.

Исходники вы можете увидеть здесь.

Автор чукча, поэтому если увидели ошибки - пишите.

Комментарии (2)


  1. Beholder
    23.11.2022 14:53
    +2

    fun String.runInShell(): Process

    Ооочень плохой пример функции-расширения. "Строка вообще" не обладает свойством "запускаться в шелле".

    Достаточно было бы просто fun runInShell(command: String): Process


    1. Renattele Автор
      23.11.2022 15:50

      Спасибо, исправил.