Skip to content

Instantly share code, notes, and snippets.

@onlymash
Last active December 3, 2025 07:41
Show Gist options
  • Select an option

  • Save onlymash/596be855370559afeebbb354b8a0d197 to your computer and use it in GitHub Desktop.

Select an option

Save onlymash/596be855370559afeebbb354b8a0d197 to your computer and use it in GitHub Desktop.
Advanced `.deb` Packaging for Compose Multiplatform Desktop Apps

Advanced .deb Packaging for Compose Multiplatform Desktop Apps

Compose Multiplatform is a fantastic framework for building desktop applications, but its default Linux packaging capabilities, which rely on jpackage, have some known limitations. A common pain point for developers is the lack of fine-grained control over the generated .deb package, especially concerning the .desktop entry file.

This often leads to issues like:

  • The application icon showing correctly in the app menu, but displaying as a generic icon in the dock or taskbar when running.
  • The inability to place icons and other resources in standard system-wide directories like /usr/share/icons.

This tutorial provides a robust, script-based solution to overcome these limitations. We will create a "post-processing" script that takes the basic .deb package generated by Gradle and enhances it into a professional, standards-compliant package. This solution was perfected during an intensive, all-night debugging session, and it's built to be both powerful and reliable.

Prerequisites

  • A working Compose Multiplatform project for Desktop.
  • A Linux environment with standard tools (dpkg-deb, ar, tar, sed, grep, etc.).
  • Your project version is managed in a gradle/libs.versions.toml file.

Step 1: Prepare Your Project Structure

First, we need to create a directory to hold our custom packaging assets.

  1. Inside your composeApp module, create the directory packaging/linux.
  2. Place your application icon (e.g., exampleapp.png) inside this new directory. A size like 256x256 or 512x512 is recommended.
  3. Create your custom .desktop file template in the same directory.

Your project structure should look like this:

.
├── gradle/
│   └── libs.versions.toml
└── composeApp/
    ├── build.gradle.kts
    ├── src/
    └── packaging/
        └── linux/
            ├── exampleapp.desktop  <-- Your custom template
            └── exampleapp.png      <-- Your application icon

The .desktop File Template

This is the core of our solution. We use a placeholder for the version and, most importantly, we can hardcode the correct StartupWMClass to fix the dock icon issue.

composeApp/packaging/linux/exampleapp.desktop:

[Desktop Entry]
Version=@version@
Type=Application
Name=ExampleApp
Comment=An example application built with Compose Multiplatform.
Exec=/opt/exampleapp/bin/ExampleApp
Icon=exampleapp
Terminal=false
Categories=Utility;Application;
StartupWMClass=com-example-MainKt
  • Version=@version@: A placeholder that our script will automatically replace.
  • StartupWMClass=...: The crucial line to ensure the running application's window is correctly matched to its launcher. (You can find your app's WM_CLASS by running it, then executing xprop WM_CLASS in a terminal and clicking on the app window. It is often derived from your main class, e.g., com.example.MainKt).

Step 2: The build-custom-deb.sh Script

This script automates the entire enhancement process. Place it in your project's root directory.

build-custom-deb.sh:

#!/bin/bash
# A script to build a custom .deb package by post-processing the one generated by Gradle.
set -e

# --- Configuration ---
APP_NAME="exampleapp"
DEB_ARCH="amd64"
TOML_FILE="gradle/libs.versions.toml"
CUSTOM_RESOURCES_DIR="composeApp/packaging/linux"
ICON_NAME="${APP_NAME}.png"
ICON_INSTALL_DIR="/usr/share/icons/hicolor/512x512/apps"
ORIGINAL_DEB_DIR="composeApp/build/compose/binaries/main-release/deb"
FINAL_DEB_DIR="composeApp/build/dist"

# --- Script Start ---

# 1. Read version from libs.versions.toml
echo "### 1. Reading version from '$TOML_FILE'..."
if [ ! -f "$TOML_FILE" ]; then
    echo "ERROR: Version catalog file not found at '$TOML_FILE'"
    exit 1
fi
VERSION=$(grep 'app-versionName' "$TOML_FILE" | cut -d '"' -f 2)
if [ -z "$VERSION" ]; then
    echo "ERROR: Could not find 'app-versionName' in '$TOML_FILE'"
    exit 1
fi
echo "  - Found app-versionName: $VERSION"


# 2. Build the basic .deb package with Gradle
echo "### 2. Building the basic DEB package with Gradle..."
./gradlew packageReleaseDeb


# 3. Find the original .deb file
ORIGINAL_DEB_PATH=$(find "$ORIGINAL_DEB_DIR" -name "*.deb")
if [ -z "$ORIGINAL_DEB_PATH" ]; then
    echo "ERROR: Original .deb file not found."
    exit 1
fi
echo "  - Found original DEB at: $ORIGINAL_DEB_PATH"


# 4. Unpack the original .deb file
WORK_DIR="composeApp/build/deb_work"
echo "### 3. Unpacking the original DEB file into '$WORK_DIR'..."
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
dpkg-deb -R "$ORIGINAL_DEB_PATH" "$WORK_DIR"


# 5. Inject custom files
echo "### 4. Injecting custom files..."

## Fixes by tavioribeiro 
# A) REMOVE DUPLICATES: Delete any .desktop file generated by jpackage
# This prevents having two icons in the application menu.
find "$WORK_DIR" -name "*.desktop" -type f -delete

# B) FIX INSTALL SCRIPTS: Remove 'xdg-desktop-menu' commands
# Since we deleted the original .desktop file, the default post-install scripts
# will fail because they try to install a file that no longer exists.
if [ -f "$WORK_DIR/DEBIAN/postinst" ]; then
    sed -i '/xdg-desktop-menu/d' "$WORK_DIR/DEBIAN/postinst"
    echo "  - Fixed 'postinst' script."
fi
if [ -f "$WORK_DIR/DEBIAN/prerm" ]; then
    sed -i '/xdg-desktop-menu/d' "$WORK_DIR/DEBIAN/prerm"
    echo "  - Fixed 'prerm' script."
fi

# a. Inject .desktop file and replace placeholder
DESKTOP_TEMPLATE="$CUSTOM_RESOURCES_DIR/$APP_NAME.desktop"
DESTINATION_DESKTOP_DIR="$WORK_DIR/usr/share/applications"
mkdir -p "$DESTINATION_DESKTOP_DIR" 
sed "s/@version@/$VERSION/g" "$DESKTOP_TEMPLATE" > "$DESTINATION_DESKTOP_DIR/$APP_NAME.desktop"
echo "  - Injected .desktop file with version '$VERSION'"

# b. Inject icon file
ICON_SOURCE_PATH="$CUSTOM_RESOURCES_DIR/$ICON_NAME"
DESTINATION_ICON_DIR="$WORK_DIR/$ICON_INSTALL_DIR"
if [ ! -f "$ICON_SOURCE_PATH" ]; then
    echo "WARNING: Icon file not found at '$ICON_SOURCE_PATH'. Skipping icon copy."
else
    mkdir -p "$DESTINATION_ICON_DIR"
    cp "$ICON_SOURCE_PATH" "$DESTINATION_ICON_DIR/"
    echo "  - Injected icon file to $ICON_INSTALL_DIR"
fi


# 6. Repack into the final .deb file with corrected ownership
FINAL_DEB_NAME="${APP_NAME}_${VERSION}_${DEB_ARCH}_custom.deb"
FINAL_DEB_PATH="$FINAL_DEB_DIR/$FINAL_DEB_NAME"
mkdir -p "$FINAL_DEB_DIR"
echo "### 5. Repacking into final DEB package..."

# The final, correct command to build a warning-free package
# Option first, then Command, then Arguments.
dpkg-deb --root-owner-group -b "$WORK_DIR" "$FINAL_DEB_PATH"


echo ""
echo "================================================="
echo "SUCCESS! Custom DEB package created at:"
echo "$FINAL_DEB_PATH"
echo "================================================="

Step 3: Usage

Using the script is straightforward.

  1. Make the script executable:
    chmod +x build-custom-deb.sh
  2. Run the script from your project's root directory:
    ./build-custom-deb.sh

The script will handle everything automatically. Upon completion, you will find your professional, ready-to-distribute .deb file in the composeApp/build/dist/ directory.

How It Works

  1. Read Version: The script starts by reading the app-versionName from your gradle/libs.versions.toml file, ensuring your package version is always in sync with your project configuration.
  2. Initial Build: It runs the standard ./gradlew packageReleaseDeb task to generate a basic .deb package.
  3. Unpack: It uses dpkg-deb -R to unpack the basic .deb file into a temporary working directory, exposing its complete filesystem structure.
  4. Inject & Customize: This is the core logic. The script copies your custom icon to the standard /usr/share/icons/... path and copies your .desktop template to /usr/share/applications. During this process, it uses sed to replace the @version@ placeholder with the actual version string.
  5. Repack: Finally, it uses dpkg-deb --build to re-assemble the modified directory into a new .deb package. The --root-owner-group flag is crucial here, as it ensures the final package has the correct file ownership (root:root), preventing any warnings during installation.

By following this guide, you can automate the creation of high-quality Debian packages for your Compose Multiplatform applications, providing a seamless and professional experience for your Linux users.

@onlymash
Copy link
Author

onlymash commented Dec 3, 2025

@tavioribeiro Thank you for fixing it. Since I'm using Arch Linux, I didn't test it on Debian.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment