Portability: A Case Study in Flutter & AppImage Distribution
Distributing a polished, cross-platform UI on Linux desktop is notoriously painful. Developers have to navigate the fragmentation of traditional .deb and .rpm packages, or step into the polarizing debates around universal formats like Snap, Flatpak, and AppImage.
For an internal app at KDAB, we maintain both Flatpak and AppImage distribution pipelines. The AppImage pipeline has been decaying since its inception. As modern Linux distributions moved from X11 to Wayland, our legacy AppImage started breaking. Instead of a seamless launch, users encountered graphics conflicts, broken GTK themes, and outright crashes. I was already using appimage-builder for this app, so there was no compelling reason to rewrite the pipeline from scratch with linuxdeploy or flutter_distributor.
The Build Baseline: Ubuntu 22.04 vs. 20.04
I chose Ubuntu 22.04 as the build baseline. The decision is primarily about glibc.
glibc is backward compatible, not forward compatible. A binary built against an old glibc runs on newer systems. The reverse fails. That is why an AppImage built on Arch usually won't start on an older distribution, and why we don't bundle our own glibc. See the libc6 line under "Excluding Core Toolchains" below.
So, the baseline sets a floor. 22.04 ships glibc 2.35 and rules out hosts older than roughly 2022, which is fine for an internal app. 20.04 ships 2.31 and reaches further back, but its standard support ended in May 2025.
Fixing the Mounting Failures
The standard runtime prepended to the AppImage relies on the host system's libfuse2 to mount the internal filesystem. Many modern distributions no longer ship fuse2 by default, so the executable fails to mount.
My approach was to keep appimage-builder for preparing the directory and package the output with go-appimagetool or a recent release of appimagetool. These ship a statically linked runtime, which removes the libfuse2 dependency.
The static runtime still relies on the host, it links FUSE 3, so it needs the kernel's FUSE support and a fusermount3 binary present. Where FUSE is unavailable entirely, APPIMAGE_EXTRACT_AND_RUN=1 unpacks the payload to a temporary directory and runs it from there.
Dependencies
An AppImage can be a full bundle that ships everything, or a thin one that leans on host libraries. The full bundle feels safe but is dangerous for graphical apps: its bundled graphics and driver libraries collide with the host's hardware. We took the middle path, bundling the UI toolkit while leaving the graphics stack and core system libraries to the host.
One thing to keep straight in the snippets below appimage-builder has two separate mechanisms. AppDir.files.exclude works on paths and globs inside the built AppDir. AppDir.apt.include and AppDir.apt.exclude work on Debian package names during dependency resolution.
Evicting the Graphics Stack
Initially, the app threw fatal errors trying to initialize the GPU context:
Couldn't open libGLESv2.so.2: cannot open shared object file: No such file or directory
No provider of eglGetPlatformDisplayEXT found. Requires one of: EGL_EXT_platform_base
[ERROR:flutter/shell/gpu/gpu_surface_gl_skia.cc(83)] Could not make the context current to set up the Gr context. Forcing the X11 backend (GDK_BACKEND=x11 ./application.AppImage) masked the problem temporarily. The root cause was that the AppImage bundled a partial graphics stack. GTK resolves its GL (OpenGL, the graphics-drawing API) and EGL (the layer connecting it to the window system) entry points at runtime through libepoxy, a dispatch library that looks the functions up on demand. With the AppDir first on the library path, epoxy found the bundled half of the stack, missed the rest, and could not assemble a working driver. Hence, a "cannot open shared object file" error for a library the host had all along.
There is also a driver-version problem. A proprietary driver's userspace libraries (for NVIDIA that is libGLX_nvidia.so, libnvidia-glcore.so, among others) must match the kernel module the host is running. A bundled libGL crashes on any system with a different driver version.
I excluded the low-level graphics and Wayland libraries from the recipe:
AppDir:
files:
exclude:
# Prefer host graphics stack to avoid driver/ABI mismatches.
- usr/lib/x86_64-linux-gnu/libGL*
- usr/lib/x86_64-linux-gnu/libEGL*
- usr/lib/x86_64-linux-gnu/libGLES*
- usr/lib/x86_64-linux-gnu/libgbm*
- usr/lib/x86_64-linux-gnu/libdrm*
- usr/lib/x86_64-linux-gnu/libwayland-* Excluding the Wayland libraries is a trade-off. The bundled GTK 3 links against libwayland-client.so.0, so it will fail to load on a host that has no Wayland libraries at all. That configuration is rare enough that we accept the trade-off.
Excluding Core Toolchains
These were already excluded in the original recipe, but they are worth restating. If you bundle libc6, the host's dynamically loaded graphics drivers try to interface with the bundled copy instead of the host's, which ends in symbol-resolution errors and crashes:
exclude:
# Keep low-level toolchain/system libs from being forcibly bundled.
- libc6
- libstdc++6
- gcc-12-base
- libgcc-s1
- zlib1g Bundling the UI Stack
The toolkit itself and its image loaders must be bundled. Without them, users saw this:
Gtk-Message: Failed to load module "window-decorations-gtk-module"
Gtk:ERROR: ensure_surface_for_gicon: assertion failed: Failed to load /org/gtk/libgtk/icons/16x16/status/image-missing.png: Unrecognized image file format (gdk-pixbuf-error-quark, 3) The first line is a separate, non-fatal issue. The host's GTK_MODULES variable injects a host module into the bundled GTK, which cannot load it. Unset or filter GTK_MODULES in the AppRun environment to silence it. The second line is the fatal one.
I bundled a coherent GTK and pixbuf userspace stack alongside the app-specific requirements:
include:
# Bundle coherent GTK/pixbuf userspace stack.
- libgtk-3-0
- libgdk-pixbuf2.0-0
- librsvg2-common
- shared-mime-info
- hicolor-icon-theme
# App-specific runtime requirements.
- libsodium23
- libsecret-1-0
- libsqlite3-0 Bundling these packages is necessary but not sufficient. gdk-pixbuf finds its image loaders through a loaders.cache file with absolute paths baked in at build time, so a bundled copy points at paths that do not exist inside the AppDir. The cache must be regenerated and GDK_PIXBUF_MODULE_FILE pointed at it. appimage-builder does this with a runtime hook.
Host vs. Bundle Runtime Priorities
With the dependencies sorted out, I configured the runtime environment to prioritize libraries intelligently:
AppDir:
runtime:
env:
# AppDir-first lookup. Missing graphics libs (excluded above) fall back to the host.
LD_LIBRARY_PATH: $APPDIR/usr/lib:$APPDIR/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH
# Prefer host theme/metadata paths, keep AppDir as fallback.
XDG_DATA_DIRS: /usr/local/share:/usr/share:$XDG_DATA_DIRS:$APPDIR/usr/share A useful rule for Linux desktop integration: libraries should prefer the bundle, while data and themes should prefer the host. The app runs against its tested dependencies while picking up the user's cursors, icons, and GTK theme.
The Linux Embedder (C++)
Flutter's viability on Linux relies heavily on its C++ host-platform embedder. I moved beyond the default "it just works" template with a few refinements to the Linux runner:
- Environment-driven decorations (CSD vs. SSD toggle): instead of auto-detecting Wayland or GNOME Shell, a custom use_header_bar function checks a HEADER_BAR environment variable. It defaults to native Server-Side Decorations (SSD) and lets users opt into Client-Side Decorations (CSD).
- Single-instance enforcement: I removed the default G_APPLICATION_NON_UNIQUE flag from the application constructor so only one instance runs at a time. This requires a valid reverse-DNS application ID (com.kdab.yourapp). GApplication uses that ID to claim a unique name on the session bus. Without it, deduplication does not take effect. (See: Industrial Flutter: Single Instance Applications)
- Window presenting: in the activate signal handler, if the app is already running, the code finds the existing window and calls gtk_window_present to raise it instead of spawning a second one. (Also covered in: Industrial Flutter: Single Instance Applications)
Validation and Conclusion
Testing graphical applications across the fragmented Linux ecosystem is difficult. The built-in appimage-builder test suite is outdated, so instead of debugging legacy tooling I wrote a small validation harness in Go, with the assistance of an LLM.
The harness runs the compiled AppImage across a matrix of Docker containers: Arch, Fedora, openSUSE, Debian, and Ubuntu 22.04 through 26.04. Each container spins up a virtual display via Xvfb and mocks the D-Bus session and Secret Service keyring, so the app boots far enough to reach its render loop instead of failing at startup on a missing desktop service. Per distribution it checks that the image extracts, that every library resolves without a fatal dlopen or missing-symbol error (the "GLIBC_2.x not found" class of failure), that X11, D-Bus, and the keyring connect, and that the process stays alive past a baseline window rather than crashing immediately. This saved me from manually testing five distributions every time I changed the recipe.
Achieving portability on Linux does not mean rewriting the pipeline from scratch. It means modernizing how we build and test. By managing the graphics stack explicitly, respecting host priorities, and automating the validation matrix, we turned a decaying AppImage pipeline into something robust and reliable.
Leave a Comment
Your Email address will not be published
KDAB is committed to ensuring that your privacy is protected.
For more information about our Privacy Policy, please read our privacy policy