Compare commits

..

No commits in common. "64fb23e62ba0557f6be2a97e1bc38f048ac5c8c5" and "069b586d445632776a7455572aabbc8ccc4c5347" have entirely different histories.

8 changed files with 8 additions and 486 deletions

View File

@ -166,7 +166,7 @@ jobs:
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf
- name: Set up JDK 21
uses: actions/setup-java@v6
@ -196,20 +196,12 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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
run: |
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)
cp "$DEB_FILE" Spotube-linux-x86_64.deb
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
uses: actions/upload-artifact@v7
@ -223,12 +215,6 @@ jobs:
name: linux-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
if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3
@ -259,7 +245,7 @@ jobs:
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf
- name: Set up JDK 21
uses: actions/setup-java@v6
@ -289,20 +275,12 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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
run: |
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)
cp "$DEB_FILE" Spotube-linux-aarch64.deb
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
uses: actions/upload-artifact@v7
@ -316,12 +294,6 @@ jobs:
name: linux-rpm-arm64
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
if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3
@ -507,7 +479,7 @@ jobs:
run: |
echo "## Downloads" > release-notes.md
echo "" >> release-notes.md
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
for dir in android-apk linux-deb linux-rpm linux-deb-arm64 linux-rpm-arm64 windows-exe macos-dmg; do
if [ -d "$dir" ]; then
echo "### $dir" >> release-notes.md
for f in "$dir"/*; do
@ -539,9 +511,7 @@ jobs:
android-apk/*.apk
linux-deb/*.deb
linux-rpm/*.rpm
linux-appimage/*.AppImage
linux-deb-arm64/*.deb
linux-rpm-arm64/*.rpm
linux-appimage-arm64/*.AppImage
windows-exe/*.exe
macos-dmg/*.dmg

View File

@ -56,18 +56,7 @@ Spotube is a Kotlin Multiplatform + Compose Multiplatform app. The desktop app i
- **Android SDK**, only for Android builds.
- **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`)
- **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.
- **Linux runtime:** `webkit2gtk-4.1` for the embedded webview.
Always use the Gradle wrapper (`./gradlew`, or `gradlew.bat` on Windows).
@ -88,7 +77,7 @@ Development builds stay fast. The release-only optimizations (the Rust release p
| OS | Requirements |
|---|---|
| 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) |
| 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` |
| 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. |
@ -115,25 +104,12 @@ Each OS can only build its own installers:
| OS | Command | Output |
|---|---|---|
| Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm`, then `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true` | `composeApp/build/compose/binaries/main/graalvm-{deb,rpm,appimage}/` |
| Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm` (also `packageGraalvmAppImage`) | `composeApp/build/compose/binaries/main/graalvm-{deb,rpm}/` |
| Windows | `./gradlew :composeApp:packageGraalvmExe` (or `packageGraalvmMsi`) | `composeApp/build/compose/binaries/main/graalvm-exe/` |
| macOS | `./gradlew :composeApp:packageGraalvmDmg` | `composeApp/build/compose/binaries/main/graalvm-dmg/` |
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)
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

@ -1,9 +0,0 @@
// 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

@ -1,260 +0,0 @@
/*
* 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,7 +23,6 @@ import dev.nucleusframework.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.reload.gradle.ComposeHotRun
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.gradle.api.tasks.Copy
import org.spotube.gradle.DownloadLinuxGtkBundleTask
import java.io.FileInputStream
import java.util.Properties
@ -107,30 +106,9 @@ 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 {
packageName = "$bundleId.core.generated" // choose a different package
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"))
}
@ -324,7 +302,6 @@ kotlin {
implementation(libs.jna)
implementation(libs.nucleus.core.runtime)
implementation(libs.nucleus.graalvm.runtime)
implementation(libs.nucleus.nucleus.application)
implementation(libs.nucleus.decorated.window.tao)
implementation(libs.compose.native.tray)
@ -424,15 +401,10 @@ nucleus.application {
nativeDistributions {
packageName = bundleId
// User-facing name (.app bundle, DMG productName, Launchpad/Dock); packageName above is the reverse-DNS id.
// The GraalVM DMG task reads only appName ?: packageName, not macOS.packageName
appName = if (isNightly) "Spotube Nightly" else "Spotube"
packageVersion = version
licenseFile = project.file("../LICENSE")
appResourcesRootDir.set(
if (bundleForAppImage) assembleAppImageResources.map { layout.buildDirectory.dir("appimage-resources").get() } else vlcjBundler.vlcNativesDirectory
)
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
targetFormats(
TargetFormat.Dmg,
@ -452,44 +424,6 @@ nucleus.application {
modules("jdk.security.auth")
debMaintainer = "Team Spotube <team.spotube@proton.me>"
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 {
iconFile.set(project.file("$desktopIconDir/icon.ico"))
@ -501,8 +435,6 @@ nucleus.application {
macOS {
iconFile.set(project.file("$desktopIconDir/icon.icns"))
bundleID = if (isNightly) "$bundleId.nightly" else bundleId
dockName = appName
notarization {
}
@ -595,8 +527,6 @@ tasks.withType<Copy>().matching { it.name == "copyGraalvmAwtSoLibs" }.configureE
// Issue with compose plugin
// https://github.com/JetBrains/compose-hot-reload/blob/master/docs/Known_limitations.md#property-composeapplicationresourcesdir-is-null-when-running-hot-reload-tasks
tasks.withType<ComposeHotRun>().configureEach {
// Populates the resources dir below with the VLC natives (appResourcesRootDir); hot run doesn't depend on it by default
dependsOn("prepareAppResources")
jvmArgs(
"--enable-native-access=ALL-UNNAMED"
)
@ -630,12 +560,3 @@ run {
.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

@ -1,68 +0,0 @@
/*
* 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,7 +38,6 @@ import spotube.composeapp.generated.resources.spotube_logo
import dev.nucleusframework.application.DecoratedWindow
import dev.nucleusframework.application.NucleusBackend
import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.graalvm.GraalVmInitializer
import dev.nucleusframework.window.tao.TaoScreenGeometry
import io.github.vinceglb.filekit.FileKit
import kotlinx.coroutines.CoroutineScope
@ -51,7 +50,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import org.koin.core.component.KoinComponent
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.i18n.AppLocaleProvider
import org.spotube.spotube.core.newpipe.NewPipeDownloader
@ -134,11 +132,6 @@ private fun restoreWindowState(saved: PersistedWindowState?): PersistedWindowSta
@OptIn(ExperimentalComposeUiApi::class, FlowPreview::class)
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")
initKoin()
NewPipeDownloader.init(KoinPathsProvider.paths)

View File

@ -72,7 +72,7 @@ ksp = "2.3.5"
spotube-gradle = "0.1.2"
cryptography = "0.6.0"
reorderable = "3.1.0"
vlcjBundler = "0.2.0"
vlcjBundler = "0.1.0"
vanniktechMavenPublish = "0.37.0"
uniffi = "0.3.7"
kotlinx-atomicfu = "0.33.0"
@ -131,7 +131,6 @@ newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version
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-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" }
semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" }
vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" }