chore: implement Linux AppImage bundling for GTK and WebKitGTK, update dependencies and documentation

This commit is contained in:
Kingkor Roy Tirtho 2026-10-19 19:15:13 +06:00
parent 069b586d44
commit e92e62613e
8 changed files with 479 additions and 8 deletions

View File

@ -166,7 +166,7 @@ jobs:
- name: Install packaging tools - name: Install packaging tools
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v6 uses: actions/setup-java@v6
@ -196,12 +196,20 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
# Only the AppImage bundles VLC, GTK and WebKitGTK; deb/rpm depend on the distro packages
- name: Package Linux AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./gradlew :composeApp:packageGraalvmAppImage -PappImage=true -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
- name: Rename Linux artifacts - name: Rename Linux artifacts
run: | run: |
DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1) DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1)
RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1) RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1)
cp "$DEB_FILE" Spotube-linux-x86_64.deb cp "$DEB_FILE" Spotube-linux-x86_64.deb
cp "$RPM_FILE" Spotube-linux-x86_64.rpm cp "$RPM_FILE" Spotube-linux-x86_64.rpm
APPIMAGE_FILE=$(find composeApp/build/compose/binaries/main -name "*.AppImage" -type f | head -1)
cp "$APPIMAGE_FILE" Spotube-linux-x86_64.AppImage
- name: Upload DEB - name: Upload DEB
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@ -215,6 +223,12 @@ jobs:
name: linux-rpm name: linux-rpm
path: Spotube-linux-x86_64.rpm path: Spotube-linux-x86_64.rpm
- name: Upload AppImage
uses: actions/upload-artifact@v7
with:
name: linux-appimage
path: Spotube-linux-x86_64.AppImage
- name: Setup tmate session on failure - name: Setup tmate session on failure
if: ${{ inputs.debug_ssh && failure() }} if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3 uses: mxschmitt/action-tmate@v3
@ -245,7 +259,7 @@ jobs:
- name: Install packaging tools - name: Install packaging tools
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v6 uses: actions/setup-java@v6
@ -275,12 +289,20 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
# Only the AppImage bundles VLC, GTK and WebKitGTK; deb/rpm depend on the distro packages
- name: Package Linux AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./gradlew :composeApp:packageGraalvmAppImage -PappImage=true -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
- name: Rename Linux artifacts - name: Rename Linux artifacts
run: | run: |
DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1) DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1)
RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1) RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1)
cp "$DEB_FILE" Spotube-linux-aarch64.deb cp "$DEB_FILE" Spotube-linux-aarch64.deb
cp "$RPM_FILE" Spotube-linux-aarch64.rpm cp "$RPM_FILE" Spotube-linux-aarch64.rpm
APPIMAGE_FILE=$(find composeApp/build/compose/binaries/main -name "*.AppImage" -type f | head -1)
cp "$APPIMAGE_FILE" Spotube-linux-aarch64.AppImage
- name: Upload DEB - name: Upload DEB
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@ -294,6 +316,12 @@ jobs:
name: linux-rpm-arm64 name: linux-rpm-arm64
path: Spotube-linux-aarch64.rpm path: Spotube-linux-aarch64.rpm
- name: Upload AppImage
uses: actions/upload-artifact@v7
with:
name: linux-appimage-arm64
path: Spotube-linux-aarch64.AppImage
- name: Setup tmate session on failure - name: Setup tmate session on failure
if: ${{ inputs.debug_ssh && failure() }} if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3 uses: mxschmitt/action-tmate@v3
@ -479,7 +507,7 @@ jobs:
run: | run: |
echo "## Downloads" > release-notes.md echo "## Downloads" > release-notes.md
echo "" >> release-notes.md echo "" >> release-notes.md
for dir in android-apk linux-deb linux-rpm linux-deb-arm64 linux-rpm-arm64 windows-exe macos-dmg; do for dir in android-apk linux-deb linux-rpm linux-appimage linux-deb-arm64 linux-rpm-arm64 linux-appimage-arm64 windows-exe macos-dmg; do
if [ -d "$dir" ]; then if [ -d "$dir" ]; then
echo "### $dir" >> release-notes.md echo "### $dir" >> release-notes.md
for f in "$dir"/*; do for f in "$dir"/*; do
@ -511,7 +539,9 @@ jobs:
android-apk/*.apk android-apk/*.apk
linux-deb/*.deb linux-deb/*.deb
linux-rpm/*.rpm linux-rpm/*.rpm
linux-appimage/*.AppImage
linux-deb-arm64/*.deb linux-deb-arm64/*.deb
linux-rpm-arm64/*.rpm linux-rpm-arm64/*.rpm
linux-appimage-arm64/*.AppImage
windows-exe/*.exe windows-exe/*.exe
macos-dmg/*.dmg macos-dmg/*.dmg

View File

@ -56,7 +56,18 @@ Spotube is a Kotlin Multiplatform + Compose Multiplatform app. The desktop app i
- **Android SDK**, only for Android builds. - **Android SDK**, only for Android builds.
- **Unpublished dependency.** One dependency isn't on Maven Central yet. Clone it and run `./gradlew publishToMavenLocal` in it: - **Unpublished dependency.** One dependency isn't on Maven Central yet. Clone it and run `./gradlew publishToMavenLocal` in it:
- [kdroidFilter/ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview), at the commit pinned in [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) (`COMPOSE_WEBVIEW_COMMIT`) - [kdroidFilter/ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview), at the commit pinned in [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) (`COMPOSE_WEBVIEW_COMMIT`)
- **Linux runtime:** `webkit2gtk-4.1` for the embedded webview. - **Linux runtime libraries** (needed to run the app, including `:composeApp:run`). The `.deb`/`.rpm` install these as dependencies from your distro. For a source build, install them yourself, **including VLC**. Only the `.AppImage` is self-contained (it bundles VLC, GTK 3 and WebKitGTK, and uses just the host's glibc, graphics, X11 and sound libraries):
| Distro | Command |
|---|---|
| Debian/Ubuntu | `sudo apt-get install libvlc5 vlc-plugin-base libwebkit2gtk-4.1-0 libgtk-3-0 libasound2 libgl1 libxtst6 libxi6 libxrender1` |
| Fedora | `sudo dnf install vlc-libs webkit2gtk4.1 gtk3 alsa-lib mesa-libGL libXtst libXi libXrender`. `vlc-libs` is only in [RPM Fusion](https://rpmfusion.org/Configuration), so enable it first. |
| Arch / CachyOS / Manjaro | `sudo pacman -S vlc webkit2gtk-4.1 gtk3 alsa-lib libglvnd libxtst libxi libxrender` |
| openSUSE | `sudo zypper install libvlc5 libwebkit2gtk-4_1-0 libgtk-3-0 libasound2 Mesa-libGL1 libXtst6 libXi6 libXrender1` (for wide codec support, switch to the [Packman](https://en.opensuse.org/Additional_package_repositories#Packman) repo's VLC) |
Arch-based distros and openSUSE have no native package here. Use the `.AppImage`. The `.rpm` is built with Fedora package names, so it won't resolve on openSUSE.
On Ubuntu 24.04+/Debian 13 `libgtk-3-0` and `libasound2` are named `libgtk-3-0t64` and `libasound2t64`; apt resolves this automatically.
Always use the Gradle wrapper (`./gradlew`, or `gradlew.bat` on Windows). Always use the Gradle wrapper (`./gradlew`, or `gradlew.bat` on Windows).
@ -77,7 +88,7 @@ Development builds stay fast. The release-only optimizations (the Rust release p
| OS | Requirements | | OS | Requirements |
|---|---| |---|---|
| Linux | `gcc`, `zlib` headers, `patchelf`; plus `rpm` for `.rpm` packages. Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm`. Fedora: `sudo dnf install gcc zlib-devel patchelf rpm-build` | | Linux | `gcc`, `zlib` headers, `patchelf`; plus `rpm` for `.rpm` packages, `fuse` for `.AppImage`, and, for the AppImage's bundled libraries, `zstd` (plus `ar`/`tar` and `readelf`, from binutils). Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm fuse zstd`. Fedora: `sudo dnf install gcc zlib-devel patchelf rpm-build fuse zstd` (also install the [Linux runtime libraries](#prerequisites) to run what you build) |
| macOS | Xcode Command Line Tools (`xcode-select --install`) | | macOS | Xcode Command Line Tools (`xcode-select --install`) |
| Windows | Visual Studio Build Tools with the "Desktop development with C++" workload. Build from an "x64 Native Tools" prompt if `native-image` can't find MSVC. | | Windows | Visual Studio Build Tools with the "Desktop development with C++" workload. Build from an "x64 Native Tools" prompt if `native-image` can't find MSVC. |
@ -104,12 +115,25 @@ Each OS can only build its own installers:
| OS | Command | Output | | OS | Command | Output |
|---|---|---| |---|---|---|
| Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm` (also `packageGraalvmAppImage`) | `composeApp/build/compose/binaries/main/graalvm-{deb,rpm}/` | | Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm`, then `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true` | `composeApp/build/compose/binaries/main/graalvm-{deb,rpm,appimage}/` |
| Windows | `./gradlew :composeApp:packageGraalvmExe` (or `packageGraalvmMsi`) | `composeApp/build/compose/binaries/main/graalvm-exe/` | | Windows | `./gradlew :composeApp:packageGraalvmExe` (or `packageGraalvmMsi`) | `composeApp/build/compose/binaries/main/graalvm-exe/` |
| macOS | `./gradlew :composeApp:packageGraalvmDmg` | `composeApp/build/compose/binaries/main/graalvm-dmg/` | | macOS | `./gradlew :composeApp:packageGraalvmDmg` | `composeApp/build/compose/binaries/main/graalvm-dmg/` |
Add `-Pnightly=true` for the nightly channel (separate icons and name). Add `-Pnightly=true` for the nightly channel (separate icons and name).
The `.deb` and `.rpm` use your distro's shared libraries and declare them as dependencies, so the package manager installs them (on Fedora, `vlc-libs` needs RPM Fusion enabled). The `.AppImage` is the self-contained one: it bundles VLC, GTK 3 and WebKitGTK (see [What the AppImage bundles](#what-the-appimage-bundles)).
#### What the AppImage bundles
Build it with `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true`. Without `-PappImage=true` nothing is bundled and the app uses the system libraries, which is what the `.deb`/`.rpm` and `:composeApp:run` do.
The bundle follows the [AppImage exclude list](https://github.com/AppImageCommunity/pkg2appimage/blob/master/excludelist) and what Tauri apps ship. It carries VLC (audio-only, from the [vlcj-bundler](https://github.com/team-spotube/vlcj-bundler-gradle-plugin) plugin's `bundleVlc` option), GTK 3, WebKitGTK 4.1, GLib/GIO, pango/cairo and their dependencies, plus glib-networking for HTTPS in the webview. It leaves the host's glibc, `libstdc++`, `libz`, GL/EGL/DRM, X11/xcb, ALSA, fontconfig/freetype/harfbuzz and the Wayland libraries alone, so the host's graphics, display and sound stack is used. Everything is taken from the Ubuntu 24.04 archive, so the AppImage needs glibc 2.38 or newer.
How it works, in short:
- [`DownloadLinuxGtkBundleTask`](buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt) downloads the packages, keeps the libraries reachable from GTK/WebKit and relocates them with `patchelf`. Needs `ar`, `tar`, `zstd`, `readelf` and `patchelf`.
- WebKitGTK has its helper-process path compiled in, so the library is patched to look relative to the working directory, and at startup the app moves there ([`AppImageEnvironment`](composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt)). It also points `GIO_EXTRA_MODULES` at the bundled TLS module and sets `WEBKIT_DISABLE_DMABUF_RENDERER=1`, because the bundled WebKitGTK 2.44's dmabuf renderer never paints (or aborts) against a newer host Mesa/compositor. The display backend is left alone: forcing X11 doesn't help, since the webview doesn't paint on XWayland either.
- Startup order matters: `main()` calls `GraalVmInitializer.initialize()` first, since the VLC loader and the environment setup read `compose.application.resources.dir`.
#### Reflection metadata (when a feature breaks only in the native build) #### Reflection metadata (when a feature breaks only in the native build)
A native image is a closed world: anything the app reaches through reflection, JNI or JNA (vlcj, the Zipline/QuickJS plugin engine, NewPipe, ktor, datastore, …) must be declared at build time. Nucleus generates most of this automatically. The rest is in [`composeApp/graalvm/reachability-metadata.json`](composeApp/graalvm/reachability-metadata.json). A native image is a closed world: anything the app reaches through reflection, JNI or JNA (vlcj, the Zipline/QuickJS plugin engine, NewPipe, ktor, datastore, …) must be declared at build time. Nucleus generates most of this automatically. The rest is in [`composeApp/graalvm/reachability-metadata.json`](composeApp/graalvm/reachability-metadata.json).

View File

@ -0,0 +1,9 @@
// Build logic for the Linux AppImage bundle (see DownloadLinuxGtkBundleTask). Lives in buildSrc so the IDE
// resolves it; an `apply(from = ...)` script plugin gets no Kotlin classpath in Android Studio.
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}

View File

@ -0,0 +1,260 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Bundles GTK 3 and WebKitGTK 4.1 into the Linux AppImage, the way Tauri apps do, so it runs on a
// system that has neither. Used only with -PappImage=true; deb/rpm depend on the distro's packages.
//
// The libraries come from the Ubuntu archive. Like the AppImage exclude list prescribes, nothing that
// has to match the host is bundled: glibc, libstdc++, libz, GL/EGL/DRM, X11/xcb, ALSA, fontconfig,
// freetype, harfbuzz, expat, gmp and the wayland libraries. What is left is pruned to the libraries
// reachable from GTK/WebKit through their ELF NEEDED entries.
//
// Layout (copied into the AppDir root by Nucleus; AppRun puts $APPDIR/usr/lib on LD_LIBRARY_PATH):
// usr/lib/*.so.N shared libraries, RUNPATH=$ORIGIN
// usr/lib/<triplet>/webkit2gtk-4.1/WebKit*Process helper processes
// usr/lib/<triplet>/gio/modules/libgiognutls.so TLS backend for WebKit/libsoup
// libwebkit2gtk has the helper path compiled in as /usr/lib/<triplet>/webkit2gtk-4.1; "/usr" is
// patched to "././" (same length), so the helpers resolve relative to the working directory, which the
// app sets to $APPDIR/usr at startup (AppImageEnvironment). Needs `ar`, `tar` (+ `zstd`), `readelf`
// and `patchelf` on the build machine.
package org.spotube.gradle
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.work.DisableCachingByDefault
import java.io.File
import java.net.URI
import java.util.zip.GZIPInputStream
@DisableCachingByDefault(because = "Downloads from the network")
abstract class DownloadLinuxGtkBundleTask : DefaultTask() {
/** Root passed on to Nucleus' `appResourcesRootDir`; the files land in `<root>/linux-<arch>/usr`. */
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
/** Ubuntu release the binaries come from. Its glibc is the minimum the bundle runs on. */
@get:Input
abstract val ubuntuRelease: Property<String>
init {
group = "dependencies"
description = "Downloads and relocates GTK 3 and WebKitGTK 4.1 for the Linux AppImage."
}
private val packages = listOf(
"libgtk-3-0t64", "libwebkit2gtk-4.1-0", "libjavascriptcoregtk-4.1-0", "glib-networking",
"libatk-bridge2.0-0t64", "libatk1.0-0t64", "libatomic1", "libatspi2.0-0t64", "libbrotli1",
"libbz2-1.0", "libcairo-gobject2", "libcairo2", "libcap2", "libdatrie1", "libdbus-1-3",
"libdw1t64", "libelf1t64", "libenchant-2-2", "libepoxy0", "libevdev2", "libffi8", "libgcrypt20",
"libgdk-pixbuf-2.0-0", "libglib2.0-0t64", "libgnutls30t64", "libgssapi-krb5-2",
"libgstreamer-gl1.0-0", "libgstreamer-plugins-base1.0-0", "libgstreamer1.0-0", "libgudev-1.0-0",
"libharfbuzz-icu0", "libhogweed6t64", "libhyphen0", "libicu74", "libidn2-0", "libjpeg-turbo8",
"libjpeg8", "libk5crypto3", "libkeyutils1", "libkrb5-3", "libkrb5support0", "liblcms2-2",
"liblz4-1", "liblzma5", "libmanette-0.2-0", "libnettle8t64", "libnghttp2-14", "liborc-0.4-0t64",
"libp11-kit0", "libpango-1.0-0", "libpangocairo-1.0-0", "libpangoft2-1.0-0", "libpixman-1-0",
"libpng16-16t64", "libpsl5t64", "libseccomp2", "libsecret-1-0", "libsharpyuv0", "libsoup-3.0-0",
"libsqlite3-0", "libsystemd0", "libtasn1-6", "libthai0", "libudev1", "libunistring5", "libunwind8",
"libwebp7", "libwebpdemux2", "libwebpmux3", "libxcb-render0", "libxcb-shm0", "libxcomposite1",
"libxcursor1", "libxdamage1", "libxext6", "libxfixes3", "libxi6", "libxinerama1", "libxkbcommon0",
"libxml2", "libxrandr2", "libxrender1", "libxslt1.1", "libzstd1",
)
// Provided by the host, per the AppImage exclude list (which includes the wayland libraries). Never
// copied even if a bundled package carries them.
private val hostLibraries = setOf(
"libc.so.6", "libm.so.6", "libdl.so.2", "libpthread.so.0", "librt.so.1", "libresolv.so.2",
"libutil.so.1", "libnsl.so.1", "libstdc++.so.6", "libgcc_s.so.1", "libz.so.1", "libGL.so.1",
"libEGL.so.1", "libGLX.so.0", "libGLdispatch.so.0", "libOpenGL.so.0", "libdrm.so.2",
"libgbm.so.1", "libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libasound.so.2",
"libfontconfig.so.1", "libfreetype.so.6", "libharfbuzz.so.0", "libexpat.so.1", "libgmp.so.10",
"libwayland-client.so.0", "libwayland-server.so.0", "libwayland-cursor.so.0",
"libwayland-egl.so.1", "libuuid.so.1", "libgpg-error.so.0", "libfribidi.so.0", "libXau.so.6",
"libXdmcp.so.6", "libpcre2-8.so.0", "libselinux.so.1", "libmount.so.1", "libblkid.so.1",
)
private val rootLibraries = listOf(
"libgtk-3.so.0", "libgdk-3.so.0", "libwebkit2gtk-4.1.so.0", "libjavascriptcoregtk-4.1.so.0",
)
@TaskAction
fun run() {
val arch = System.getProperty("os.arch").lowercase()
val isArm = arch.contains("aarch64") || arch.contains("arm64")
val resourceDir = if (isArm) "linux-arm64" else "linux-x64"
val debArch = if (isArm) "arm64" else "amd64"
val triplet = if (isArm) "aarch64-linux-gnu" else "x86_64-linux-gnu"
val mirror = if (isArm) "http://ports.ubuntu.com/ubuntu-ports" else "http://archive.ubuntu.com/ubuntu"
val target = outputDir.dir(resourceDir).get().asFile
val marker = File(target, ".complete")
if (marker.exists()) {
println("Linux GTK/WebKit bundle already present. Skipping download.")
return
}
for (tool in listOf("ar", "tar", "readelf", "patchelf")) {
val found = try {
ProcessBuilder(tool, "--version").redirectErrorStream(true).start()
.also { it.inputStream.readBytes() }.waitFor() == 0
} catch (_: Exception) {
false
}
if (!found) throw GradleException("`$tool` is required to bundle GTK/WebKit on Linux but was not found on PATH.")
}
target.deleteRecursively()
val work = File(temporaryDir, "work").apply { deleteRecursively(); mkdirs() }
val root = File(work, "root").apply { mkdirs() }
val index = fetchPackageIndex(mirror, debArch)
for (pkg in packages) {
val filename = index[pkg] ?: throw GradleException("Package $pkg not found in the ${ubuntuRelease.get()} index")
val deb = File(work, "$pkg.deb")
println("Downloading $pkg")
URI("$mirror/$filename").toURL().openStream().use { input -> deb.outputStream().use { input.copyTo(it) } }
extractDeb(deb, root, File(work, "ar-$pkg"))
}
val srcLib = File(root, "usr/lib/$triplet")
val libOut = File(target, "usr/lib").apply { mkdirs() }
// Libraries by SONAME (libbz2.so.1.0 and friends don't end in a single number)
val available = HashMap<String, File>()
srcLib.listFiles { f -> f.isFile && ".so" in f.name }.orEmpty().forEach { file ->
soname(file)?.let { available.putIfAbsent(it, file) }
}
val helperSrc = File(srcLib, "webkit2gtk-4.1")
val helperOut = File(libOut, "$triplet/webkit2gtk-4.1")
val helpers = listOf("WebKitWebProcess", "WebKitNetworkProcess", "WebKitGPUProcess")
for (name in helpers) {
// copyTo drops the mode bits; WebKit spawns these, so they must stay executable
File(helperSrc, name).copyTo(File(helperOut, name), overwrite = true).setExecutable(true, false)
}
val injected = File(helperSrc, "injected-bundle/libwebkit2gtkinjectedbundle.so")
injected.copyTo(File(helperOut, "injected-bundle/${injected.name}"), overwrite = true)
val gioTls = File(srcLib, "gio/modules/libgiognutls.so")
gioTls.copyTo(File(libOut, "$triplet/gio/modules/${gioTls.name}"), overwrite = true)
// Keep only what GTK, WebKit, their helpers and the TLS module pull in via NEEDED
val pending = ArrayDeque(rootLibraries)
listOf(*helpers.map { File(helperOut, it) }.toTypedArray(), File(helperOut, "injected-bundle/${injected.name}"),
File(libOut, "$triplet/gio/modules/${gioTls.name}")).forEach { pending += needed(it) }
val keep = LinkedHashSet<String>()
while (pending.isNotEmpty()) {
val name = pending.removeFirst()
if (name in hostLibraries || name in keep) continue
val file = available[name] ?: continue
keep += name
pending += needed(file)
}
for (name in keep) available.getValue(name).copyTo(File(libOut, name), overwrite = true)
patchWebKitPaths(File(libOut, "libwebkit2gtk-4.1.so.0"), triplet)
// Libraries find each other next to themselves; the helpers and modules sit two/three levels down
libOut.listFiles { f -> f.isFile }.orEmpty().forEach { patchRpath(it, "\$ORIGIN") }
helpers.forEach { patchRpath(File(helperOut, it), "\$ORIGIN/../..") }
patchRpath(File(helperOut, "injected-bundle/${injected.name}"), "\$ORIGIN/../../..")
patchRpath(File(libOut, "$triplet/gio/modules/${gioTls.name}"), "\$ORIGIN/../../..")
marker.writeText("")
work.deleteRecursively()
println("GTK/WebKit bundled into $target (${keep.size} libraries)")
}
/** "/usr/lib/<triplet>/webkit2gtk-4.1[/injected-bundle]" -> "././lib/<triplet>/webkit2gtk-4.1[/...]". */
private fun patchWebKitPaths(lib: File, triplet: String) {
val bytes = lib.readBytes()
val needle = "/usr/lib/$triplet/webkit2gtk-4.1".toByteArray()
val replacement = "././".toByteArray()
var count = 0
var i = 0
while (i <= bytes.size - needle.size) {
var match = true
for (j in needle.indices) {
if (bytes[i + j] != needle[j]) { match = false; break }
}
if (match) {
replacement.copyInto(bytes, i)
count++
i += needle.size
} else {
i++
}
}
if (count == 0) throw GradleException("WebKit helper path not found in ${lib.name}; the bundling layout needs updating")
lib.writeBytes(bytes)
}
private fun soname(file: File): String? =
exec(file.parentFile, "readelf", "-d", file.absolutePath).lineSequence()
.firstOrNull { "(SONAME)" in it }?.substringAfter('[')?.substringBefore(']')
private fun needed(file: File): List<String> =
exec(file.parentFile, "readelf", "-d", file.absolutePath).lineSequence()
.filter { "(NEEDED)" in it }.map { it.substringAfter('[').substringBefore(']') }.toList()
/** name -> pool filename; `-updates` is read first so security/bugfix builds win. */
private fun fetchPackageIndex(mirror: String, debArch: String): Map<String, String> {
val release = ubuntuRelease.get()
val index = HashMap<String, String>()
for (suite in listOf("$release-updates", release)) {
for (component in listOf("main", "universe")) {
val url = URI("$mirror/dists/$suite/$component/binary-$debArch/Packages.gz").toURL()
GZIPInputStream(url.openStream()).bufferedReader().useLines { lines ->
var name: String? = null
for (line in lines) {
when {
line.startsWith("Package: ") -> name = line.removePrefix("Package: ")
line.startsWith("Filename: ") -> name?.let { index.putIfAbsent(it, line.removePrefix("Filename: ")) }
line.isEmpty() -> name = null
}
}
}
}
}
return index
}
private fun extractDeb(deb: File, into: File, scratch: File) {
scratch.mkdirs()
exec(scratch, "ar", "x", deb.absolutePath)
val data = scratch.listFiles { f -> f.name.startsWith("data.tar") }?.singleOrNull()
?: throw GradleException("No data archive in ${deb.name}")
// GNU tar picks the decompressor from the content (zstd needs the zstd binary installed)
exec(scratch, "tar", "-xf", data.absolutePath, "-C", into.absolutePath)
}
private fun patchRpath(file: File, rpath: String) {
exec(file.parentFile, "patchelf", "--set-rpath", rpath, file.absolutePath)
}
private fun exec(dir: File, vararg command: String): String {
val process = ProcessBuilder(*command).directory(dir).redirectErrorStream(true).start()
val output = process.inputStream.bufferedReader().readText()
if (process.waitFor() != 0) {
throw GradleException("`${command.joinToString(" ")}` failed:\n$output")
}
return output
}
}

View File

@ -23,6 +23,7 @@ import dev.nucleusframework.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.reload.gradle.ComposeHotRun import org.jetbrains.compose.reload.gradle.ComposeHotRun
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.gradle.api.tasks.Copy import org.gradle.api.tasks.Copy
import org.spotube.gradle.DownloadLinuxGtkBundleTask
import java.io.FileInputStream import java.io.FileInputStream
import java.util.Properties import java.util.Properties
@ -106,9 +107,30 @@ tasks.configureEach {
} }
} }
// Windows/macOS always bundle VLC. On Linux only the self-contained AppImage (-PappImage=true) bundles
// VLC, GTK and WebKitGTK; deb/rpm use the distro's packages.
val isLinuxHost = System.getProperty("os.name").lowercase().contains("linux")
val bundleForAppImage = isLinuxHost && providers.gradleProperty("appImage").map { it.toBoolean() }.getOrElse(false)
vlcjBundler { vlcjBundler {
packageName = "$bundleId.core.generated" // choose a different package packageName = "$bundleId.core.generated" // choose a different package
objectName = "VLCBundleLoaderGenerated" // or rename the object objectName = "VLCBundleLoaderGenerated" // or rename the object
bundleVlc = !isLinuxHost || bundleForAppImage
}
// GTK 3 + WebKitGTK for the AppImage (see DownloadLinuxGtkBundleTask in buildSrc)
val downloadLinuxGtkBundle = tasks.register<DownloadLinuxGtkBundleTask>("downloadLinuxGtkBundle") {
outputDir.set(layout.buildDirectory.dir("linux-gtk-bundle"))
ubuntuRelease.set("noble")
}
val linuxGtkBundleDir: Provider<Directory> = downloadLinuxGtkBundle.flatMap { it.outputDir }
// One resources root holding VLC and GTK/WebKit, laid out for the AppImage
val assembleAppImageResources = tasks.register<Sync>("assembleAppImageResources") {
from(vlcjBundler.vlcNativesDirectory)
from(linuxGtkBundleDir)
exclude("**/.complete")
into(layout.buildDirectory.dir("appimage-resources"))
} }
@ -302,6 +324,7 @@ kotlin {
implementation(libs.jna) implementation(libs.jna)
implementation(libs.nucleus.core.runtime) implementation(libs.nucleus.core.runtime)
implementation(libs.nucleus.graalvm.runtime)
implementation(libs.nucleus.nucleus.application) implementation(libs.nucleus.nucleus.application)
implementation(libs.nucleus.decorated.window.tao) implementation(libs.nucleus.decorated.window.tao)
implementation(libs.compose.native.tray) implementation(libs.compose.native.tray)
@ -404,7 +427,9 @@ nucleus.application {
packageVersion = version packageVersion = version
licenseFile = project.file("../LICENSE") licenseFile = project.file("../LICENSE")
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory) appResourcesRootDir.set(
if (bundleForAppImage) assembleAppImageResources.map { layout.buildDirectory.dir("appimage-resources").get() } else vlcjBundler.vlcNativesDirectory
)
targetFormats( targetFormats(
TargetFormat.Dmg, TargetFormat.Dmg,
@ -424,6 +449,44 @@ nucleus.application {
modules("jdk.security.auth") modules("jdk.security.auth")
debMaintainer = "Team Spotube <team.spotube@proton.me>" debMaintainer = "Team Spotube <team.spotube@proton.me>"
rpmLicenseType = "AGPL-3.0-or-later" rpmLicenseType = "AGPL-3.0-or-later"
// deb/rpm use the distro's shared libraries (only the AppImage bundles VLC). VLC is not
// bundled on Linux otherwise, the webview needs WebKitGTK 4.1, and the window/tray/MPRIS
// natives link GTK 3, GLib/GIO and D-Bus. Setting these replaces electron-builder's
// Electron-specific defaults (libnss3, libnotify, ...).
debDepends = listOf(
"libvlc5",
"vlc-plugin-base",
"libwebkit2gtk-4.1-0",
"libgtk-3-0 | libgtk-3-0t64",
"libglib2.0-0 | libglib2.0-0t64",
"libdbus-1-3",
"libasound2 | libasound2t64",
"libgl1",
"libfontconfig1",
"libfreetype6",
"libx11-6",
"libxext6",
"libxi6",
"libxrender1",
"libxtst6",
)
// vlc-libs comes from RPM Fusion (Fedora ships no VLC); the README tells users to enable it
rpmRequires = listOf(
"vlc-libs",
"webkit2gtk4.1",
"gtk3",
"glib2",
"dbus-libs",
"alsa-lib",
"mesa-libGL",
"fontconfig",
"freetype",
"libX11",
"libXext",
"libXi",
"libXrender",
"libXtst",
)
} }
windows { windows {
iconFile.set(project.file("$desktopIconDir/icon.ico")) iconFile.set(project.file("$desktopIconDir/icon.ico"))
@ -560,3 +623,12 @@ run {
.configureEach { enabled = false } .configureEach { enabled = false }
} }
} }
// Nucleus' resource copies (prepareAppResources, copyGraalvmAppResources) drop the execute bit, but WebKitGTK
// spawns its helper processes from the AppImage bundle (DownloadLinuxGtkBundleTask in buildSrc) and fails with
// "Permission denied" otherwise.
tasks.matching { it.name == "prepareAppResources" || it.name == "copyGraalvmAppResources" }.configureEach {
(this as? org.gradle.api.tasks.AbstractCopyTask)?.filesMatching("**/webkit2gtk-4.1/WebKit*Process") {
permissions { unix("rwxr-xr-x") }
}
}

View File

@ -0,0 +1,68 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.appimage
import com.sun.jna.Function
import com.sun.jna.NativeLibrary
import java.io.File
/**
* Makes the GTK 3 and WebKitGTK bundled into the AppImage usable. The AppImage's AppRun already puts
* `$APPDIR/usr/lib` on `LD_LIBRARY_PATH`; the rest has to be done in-process, before any GTK/WebKit code
* loads, because the AppRun is generated and can't be customised:
*
* - WebKitGTK has its helper-process path compiled in. The build patches it to a path relative to the
* working directory (see `DownloadLinuxGtkBundleTask` in buildSrc), so the working directory is moved to
* `$APPDIR/usr`.
* - TLS in WebKit/libsoup needs glib-networking's GIO module, which lives in the bundle.
* - The bundled WebKitGTK 2.44 uses its dmabuf renderer by default. Against a newer host Mesa/compositor
* that path never paints the first frame (the webview stays see-through or black until the window is
* resized), and the bundled GTK/epoxy can even abort while creating the GL context. Disabling it makes
* the webview paint on native Wayland. Left alone if the user already set it.
*
* Does nothing outside the AppImage (deb/rpm/dev runs have no bundled `usr/lib/libgtk-3.so.0`).
*/
object AppImageEnvironment {
fun apply() {
if (!System.getProperty("os.name").lowercase().contains("linux")) return
val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return
val usrDir = File(resourcesDir, "usr")
if (!File(usrDir, "lib/libgtk-3.so.0").isFile) return
try {
val libc = NativeLibrary.getInstance("c")
val setenv = libc.getFunction("setenv")
val triplet = if (System.getProperty("os.arch").lowercase().let { "aarch64" in it || "arm64" in it }) {
"aarch64-linux-gnu"
} else {
"x86_64-linux-gnu"
}
setenv.set("GIO_EXTRA_MODULES", File(usrDir, "lib/$triplet/gio/modules").absolutePath)
if (System.getenv("WEBKIT_DISABLE_DMABUF_RENDERER").isNullOrBlank()) {
setenv.set("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
}
libc.getFunction("chdir").invokeInt(arrayOf<Any>(usrDir.absolutePath))
} catch (_: Throwable) {
// Falls back to the host's libraries, which only works where they are installed
}
}
private fun Function.set(name: String, value: String) {
invokeInt(arrayOf<Any>(name, value, 1))
}
}

View File

@ -38,6 +38,7 @@ import spotube.composeapp.generated.resources.spotube_logo
import dev.nucleusframework.application.DecoratedWindow import dev.nucleusframework.application.DecoratedWindow
import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.NucleusBackend
import dev.nucleusframework.application.nucleusApplication import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.graalvm.GraalVmInitializer
import dev.nucleusframework.window.tao.TaoScreenGeometry import dev.nucleusframework.window.tao.TaoScreenGeometry
import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.FileKit
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -50,6 +51,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import org.spotube.spotube.core.appimage.AppImageEnvironment
import org.spotube.spotube.core.di.initKoin import org.spotube.spotube.core.di.initKoin
import org.spotube.spotube.core.i18n.AppLocaleProvider import org.spotube.spotube.core.i18n.AppLocaleProvider
import org.spotube.spotube.core.newpipe.NewPipeDownloader import org.spotube.spotube.core.newpipe.NewPipeDownloader
@ -132,6 +134,11 @@ private fun restoreWindowState(saved: PersistedWindowState?): PersistedWindowSta
@OptIn(ExperimentalComposeUiApi::class, FlowPreview::class) @OptIn(ExperimentalComposeUiApi::class, FlowPreview::class)
fun main() { fun main() {
// Sets compose.application.resources.dir in the native image. initKoin() creates the audio
// player, whose VLC loader reads it, so it can't wait for nucleusApplication() (which calls
// this again, harmlessly). Without it the bundled VLC is skipped for the system one.
GraalVmInitializer.initialize()
AppImageEnvironment.apply()
FileKit.init(appId = "org.spotube.spotube") FileKit.init(appId = "org.spotube.spotube")
initKoin() initKoin()
NewPipeDownloader.init(KoinPathsProvider.paths) NewPipeDownloader.init(KoinPathsProvider.paths)

View File

@ -72,7 +72,7 @@ ksp = "2.3.5"
spotube-gradle = "0.1.2" spotube-gradle = "0.1.2"
cryptography = "0.6.0" cryptography = "0.6.0"
reorderable = "3.1.0" reorderable = "3.1.0"
vlcjBundler = "0.1.0" vlcjBundler = "0.2.0"
vanniktechMavenPublish = "0.37.0" vanniktechMavenPublish = "0.37.0"
uniffi = "0.3.7" uniffi = "0.3.7"
kotlinx-atomicfu = "0.33.0" kotlinx-atomicfu = "0.33.0"
@ -131,6 +131,7 @@ newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version
nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" } nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" }
nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleusNucleusApplication" } nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleusNucleusApplication" }
nucleus-media-control = { module = "dev.nucleusframework:nucleus.media-control", version.ref = "nucleusNucleusApplication" } nucleus-media-control = { module = "dev.nucleusframework:nucleus.media-control", version.ref = "nucleusNucleusApplication" }
nucleus-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-runtime", version.ref = "nucleusNucleusApplication" }
nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" } nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" }
semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" } semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" }
vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" }