[kde-linux/package-compatibility-helper] /: Add on-demand Flatpak compatibility tools

Thomas Duckworth <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit d86983a7d2f4b296854a0fa49a1e5254eb1f9f3f by Thomas Duckworth, on behalf of Hadi Chokr.
Committed on 24/07/2026 at 10:39.
Pushed by tduck into branch 'master'.

Add on-demand Flatpak compatibility tools

Add `package-compatibility-helper --install <config-name>` for command
shims, MIME handlers, and binfmt interpreters. It installs the configured
Flatpak on first use, then the shim runs the requested command through
Flatpak. The command exits 0 only when the tool is installed, 1 when the
user declines, cancels, or installation fails, and 2 for invalid usage or
configuration.

CompatibilityToolInstaller combines compatibility-tool configuration
discovery with Flatpak installation. Configs are read from
<sysconfdir>/package-compatibility-helper/apps first, then the data
directory, so downstreams can provide their own tools. A config describes
the app, remote, optional post-install command, and supported MIME types.
Installed-state detection checks both user and system Flatpak installations,
including tools installed manually.

CompatibilityHelperFactory attaches a matching configured tool to native
helpers and uses GenericCompatibilityHelper for otherwise unsupported
configured MIME types. This replaces the hard-coded Wine installation
handling with data/apps/wine.conf.

The GUI has a single QML entry point, Main.qml. In install-only mode it
immediately presents InstallPage; file handling instead presents the normal
compatibility-helper UI before offering installation. InstallPage supports
size lookup, progress, cancellation, errors, and the correct standalone exit
status.

CompatibilityToolInstaller uses libflatpak on a worker thread to add the
configured remote when necessary, resolve download and installed sizes, and
run a transaction pinned to that remote. A remote added for a declined
installation is removed again. Post-install commands and desktop-service
cache refreshes run after success.

Add integration trees for image builds. Tree command and binfmt shims call
package-compatibility-helper-run, which checks the actual Flatpak ref before
starting installation. The bundled kjar example supplies java, javac, and
binfmt integration; its .jar MIME type is registered on Package
Compatibility Helper’s desktop entry. new-tree.py also generates dedicated
desktop MIME shims for downstream trees added at runtime.

Closes kde-linux#447 kde-linux#532

Signed-off-by: Hadi Chokr <[email protected]>

M  +23   -1    CMakeLists.txt
M  +32   -0    README.MD
A  +90   -0    cmake/PackageCompatibilityHelperInstallTrees.cmake
A  +11   -0    data/apps/wine.conf
A  +41   -0    data/package-compatibility-helper-run.in
M  +7    -0    src/CMakeLists.txt
M  +29   -11   src/CompatibilityHelperFactory.cpp
M  +1    -0    src/CompatibilityHelperFactory.h
A  +616  -0    src/CompatibilityToolInstaller.cpp     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.0)]
A  +98   -0    src/CompatibilityToolInstaller.h     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.0)]
M  +0    -27   src/DebCompatibilityHelper.h
A  +99   -0    src/GenericCompatibilityHelper.cpp     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.0)]
A  +40   -0    src/GenericCompatibilityHelper.h     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.0)]
M  +88   -5    src/ICompatibilityHelper.cpp
M  +29   -9    src/ICompatibilityHelper.h
M  +0    -27   src/RpmCompatibilityHelper.h
M  +2    -37   src/WindowsCompatibilityHelper.cpp
M  +0    -12   src/WindowsCompatibilityHelper.h
A  +180  -0    src/contents/ui/InstallPage.qml     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.0)]
M  +69   -17   src/contents/ui/Main.qml
M  +3    -0    src/directories.h.in
M  +56   -27   src/main.cpp
M  +2    -3    src/org.kde.package-compatibility-helper.desktop
A  +181  -0    tools/new-tree.py
A  +4    -0    trees/kjar/usr/bin/java.in
A  +4    -0    trees/kjar/usr/bin/javac.in
A  +3    -0    trees/kjar/usr/lib/binfmt.d/kjar.conf.in
A  +4    -0    trees/kjar/usr/libexec/kjar-binfmt.in
A  +15   -0    trees/kjar/usr/share/package-compatibility-helper/apps/kjar.conf.in

https://invent.kde.org/kde-linux/package-compatibility-helper/-/commit/d86983a7d2f4b296854a0fa49a1e5254eb1f9f3f

diff --git a/CMakeLists.txt b/CMakeLists.txt
index a17402a..dd24319 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -12,7 +12,8 @@ set(KF6_MIN_VERSION 6.12.0)
 
 find_package(ECM ${KF6_MIN_VERSION} REQUIRED NO_MODULE)
 
-set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${ECM_MODULE_PATH})
+set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${ECM_MODULE_PATH}
+                      ${CMAKE_CURRENT_SOURCE_DIR}/cmake)
 set(CMAKE_EXPORT_COMPILE_COMMANDS TRUE)
 
 include(FeatureSummary)
@@ -36,6 +37,7 @@ find_package(
   Qt6 ${QT6_MIN_VERSION} REQUIRED
   COMPONENTS Core
              Gui
+             Network
              Qml
              QuickControls2
              Svg
@@ -44,6 +46,9 @@ find_package(
 find_package(KF6 ${KF6_MIN_VERSION} REQUIRED COMPONENTS Kirigami CoreAddons
                                                         I18n KIO)
 
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(Flatpak REQUIRED IMPORTED_TARGET flatpak>=1.4)
+
 qt_policy(SET QTP0001 NEW)
 
 ecm_find_qmlmodule(org.kde.kirigamiaddons.formcard 1.0)
@@ -55,6 +60,23 @@ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/src/directories.h.in
 
 ki18n_install(po)
 
+# Launcher used by the shims in trees/. It chains to
+# `package-compatibility-helper --install <name>` when the Flatpak is missing.
+configure_file(${CMAKE_CURRENT_SOURCE_DIR}/data/package-compatibility-helper-run.in
+               ${CMAKE_CURRENT_BINARY_DIR}/data/package-compatibility-helper-run @ONLY)
+install(PROGRAMS ${CMAKE_CURRENT_BINARY_DIR}/data/package-compatibility-helper-run
+        DESTINATION ${KDE_INSTALL_LIBEXECDIR})
+
+# Integration trees (command shims, MIME handlers, binfmt rules plus an
+# installer config). The install logic lives in cmake/PackageCompatibilityHelperInstallTrees.cmake.
+set(PACKAGE_COMPATIBILITY_HELPER_TREES "all" CACHE STRING
+    "Semicolon-separated list of trees to install, 'all', or empty for none")
+
+if(NOT PACKAGE_COMPATIBILITY_HELPER_TREES STREQUAL "")
+    include(PackageCompatibilityHelperInstallTrees)
+    package_compatibility_helper_install_trees(TREES ${PACKAGE_COMPATIBILITY_HELPER_TREES})
+endif()
+
 feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
 
 file(GLOB_RECURSE ALL_CLANG_FORMAT_SOURCE_FILES src/*.cpp src/*.h)
diff --git a/README.MD b/README.MD
index 33de115..6100e69 100644
--- a/README.MD
+++ b/README.MD
@@ -13,3 +13,35 @@ Can be built and installed with KDE Builder:
 ```
 kde-builder package-compatibility-helper
 ```
+
+## Installing Flatpaks on first use
+
+The helper can also install the Flatpak behind a command, file type or
+binfmt interpreter the first time it is used. A tree under `trees/`
+ships shims into `/usr`. The shims call
+`package-compatibility-helper-run`, which starts
+`package-compatibility-helper --install <name>` if the Flatpak is
+missing (a wizard asks the user, adds the remote and installs it) and
+then runs the real command. After the first install the shim calls the
+Flatpak directly.
+
+`--install` exits 0 if the Flatpak is installed, 1 if the user declined
+or the installation failed, and 2 on bad usage or a broken config.
+Configs live in `/usr/share/package-compatibility-helper/apps/`;
+downstream consumers can ship their own or override them in
+`/etc/package-compatibility-helper/apps/`, which takes precedence.
+
+A config can also list comma-separated MIME types with `MimeTypes=` in its `[App]` section. The
+graphical helper then offers that Flatpak as the compatibility tool
+when a matching file is opened, installing it first if needed. This
+also works for file types the helper has no dedicated support for:
+a generic dialog with the usual "Open With…", "Get Help" and
+"Install/Run with <tool>" actions is shown, and trees register the
+helper as the handler for their MIME types. See `data/apps/wine.conf`,
+which provides Wine for Windows executables.
+
+Trees are only installed when selected with `-DPACKAGE_COMPATIBILITY_HELPER_TREES=<name>` (or
+`all`), since they are intended for OS image builds. See `trees/kjar`
+for an example that makes `java`, `javac` and `.jar` files work by
+installing a JDK Flatpak, and `tools/new-tree.py` for scaffolding new
+trees.
diff --git a/cmake/PackageCompatibilityHelperInstallTrees.cmake b/cmake/PackageCompatibilityHelperInstallTrees.cmake
new file mode 100644
index 0000000..057eda8
--- /dev/null
+++ b/cmake/PackageCompatibilityHelperInstallTrees.cmake
@@ -0,0 +1,90 @@
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+# SPDX-License-Identifier: BSD-3-Clause
+
+#[=======================================================================[.rst:
+PackageCompatibilityHelperInstallTrees
+--------------------------------------
+
+Installs the integration trees (command shims, MIME handlers, binfmt
+rules plus an installer config) shipped under ``trees/``.
+
+::
+
+  package_compatibility_helper_install_trees(TREES <name>... | all)
+
+``TREES`` is a list of tree directory names under ``trees/``, or
+``all`` to install every available tree.
+
+Tree files are templates: ``*.in`` files get ``@KDE_INSTALL_*@`` macros
+substituted so shims and binfmt rules point at the real install dirs
+(Arch uses /usr/lib as libexecdir, not /usr/libexec). ``usr/bin`` and
+``usr/libexec`` map to the configured install dirs; everything else
+keeps its path relative to the prefix.
+#]=======================================================================]
+
+function(package_compatibility_helper_install_trees)
+    cmake_parse_arguments(PARSE_ARGV 0 ARG "" "" "TREES")
+
+    if(NOT ARG_TREES)
+        return()
+    endif()
+
+    if(NOT CMAKE_INSTALL_PREFIX STREQUAL "/usr")
+        message(WARNING
+            "systemd only reads binfmt rules from /usr/lib/binfmt.d and "
+            "shims must be on the default PATH, but CMAKE_INSTALL_PREFIX "
+            "is '${CMAKE_INSTALL_PREFIX}'. Use DESTDIR for staged installs.")
+    endif()
+
+    set(_trees_root ${CMAKE_CURRENT_SOURCE_DIR}/trees)
+
+    file(GLOB _tree_candidates RELATIVE ${_trees_root}
+        CONFIGURE_DEPENDS ${_trees_root}/*)
+    set(_available "")
+    foreach(_entry IN LISTS _tree_candidates)
+        if(IS_DIRECTORY ${_trees_root}/${_entry})
+            list(APPEND _available ${_entry})
+        endif()
+    endforeach()
+
+    if(ARG_TREES STREQUAL "all")
+        set(_selected ${_available})
+    else()
+        set(_selected ${ARG_TREES})
+    endif()
+
+    foreach(tree IN LISTS _selected)
+        set(_tree_dir ${_trees_root}/${tree})
+        if(NOT IS_DIRECTORY ${_tree_dir}/usr)
+            message(FATAL_ERROR
+                "Tree '${tree}' does not exist or has no usr/ directory. "
+                "Available trees: ${_available}")
+        endif()
+        message(STATUS "Installing tree: ${tree}")
+
+        file(GLOB_RECURSE _tree_files RELATIVE ${_tree_dir}
+            CONFIGURE_DEPENDS ${_tree_dir}/usr/*)
+        foreach(_file IN LISTS _tree_files)
+            set(_src ${_tree_dir}/${_file})
+            set(_rel ${_file})
+            if(_rel MATCHES "\\.in$")
+                string(REGEX REPLACE "\\.in$" "" _rel "${_rel}")
+                set(_configured ${CMAKE_CURRENT_BINARY_DIR}/trees/${tree}/${_rel})
+                configure_file(${_src} ${_configured} @ONLY)
+                set(_src ${_configured})
+            endif()
+            get_filename_component(_subdir ${_rel} DIRECTORY)
+            if(_subdir STREQUAL "usr/bin")
+                install(PROGRAMS ${_src} DESTINATION ${KDE_INSTALL_BINDIR})
+            elseif(_subdir STREQUAL "usr/libexec")
+                install(PROGRAMS ${_src} DESTINATION ${KDE_INSTALL_LIBEXECDIR})
+            else()
+                string(REGEX REPLACE "^usr/" "" _dest "${_subdir}")
+                if(_dest STREQUAL "")
+                    set(_dest .)
+                endif()
+                install(FILES ${_src} DESTINATION ${_dest})
+            endif()
+        endforeach()
+    endforeach()
+endfunction()
diff --git a/data/apps/wine.conf b/data/apps/wine.conf
new file mode 100644
index 0000000..5866211
--- /dev/null
+++ b/data/apps/wine.conf
@@ -0,0 +1,11 @@
+# SPDX-License-Identifier: LGPL-2.0-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+[App]
+Id=org.winehq.Wine
+Name=Wine
+Icon=wine-symbolic
+MimeTypes=application/x-ms-dos-executable,application/x-msi,application/x-ms-shortcut,application/vnd.microsoft.portable-executable,application/x-msdownload
+
+[Remote]
+Name=flathub
+Url=https://dl.flathub.org/repo/flathub.flatpakrepo
diff --git a/data/package-compatibility-helper-run.in b/data/package-compatibility-helper-run.in
new file mode 100644
index 0000000..271e4ca
--- /dev/null
+++ b/data/package-compatibility-helper-run.in
@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: NONE
+#
+# Launcher for the shims installed by the trees in trees/. Installs the
+# tree's Flatpak first if needed, then runs the real command.
+#
+# Usage: package-compatibility-helper-run <config-name> [command] [args...]
+
+set -euo pipefail
+
+CONFIG_NAME="${1:?Usage: package-compatibility-helper-run <config-name> [command] [args...]}"
+shift
+
+# Sysconfdir first, so configs in /etc override the ones in datadir.
+CONFIG_FILE=""
+for dir in "@KDE_INSTALL_FULL_SYSCONFDIR@/@PROJECT_NAME@/apps" "@KDE_INSTALL_FULL_DATADIR@/@PROJECT_NAME@/apps"; do
+    if [[ -f "$dir/$CONFIG_NAME.conf" ]]; then
+        CONFIG_FILE="$dir/$CONFIG_NAME.conf"
+        break
+    fi
+done
+if [[ -z "$CONFIG_FILE" ]]; then
+    echo "package-compatibility-helper-run: no such config: $CONFIG_NAME" >&2
+    exit 1
+fi
+
+# Read Id= from the [App] section (first match wins).
+APP_ID=$(sed -n 's/^Id[[:space:]]*=[[:space:]]*//p' "$CONFIG_FILE" | head -n1)
+if [[ -z "$APP_ID" ]]; then
+    echo "package-compatibility-helper-run: config $CONFIG_FILE has no App Id" >&2
+    exit 1
+fi
+
+# Ask Flatpak for the ref itself. The installer exits non-zero if it does
+# not get installed.
+if ! flatpak info "$APP_ID" >/dev/null 2>&1; then
+    "@KDE_INSTALL_FULL_BINDIR@/package-compatibility-helper" --install "$CONFIG_NAME"
+fi
+
+exec flatpak run "$APP_ID" "$@"
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 92ba9b0..46a29b7 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -8,6 +8,7 @@ qt_add_qml_module(packagecompatibilityhelper_static
     VERSION 1.0
     QML_FILES
         contents/ui/Main.qml
+        contents/ui/InstallPage.qml
 )
 
 target_sources(packagecompatibilityhelper_static PUBLIC
@@ -16,12 +17,15 @@ target_sources(packagecompatibilityhelper_static PUBLIC
     WindowsCompatibilityHelper.cpp
     RpmCompatibilityHelper.cpp
     DebCompatibilityHelper.cpp
+    GenericCompatibilityHelper.cpp
     PackageUtils.cpp
+    CompatibilityToolInstaller.cpp
 )
 
 target_link_libraries(packagecompatibilityhelper_static PUBLIC
     Qt6::Core
     Qt6::Gui
+    Qt6::Network
     Qt6::Qml
     Qt6::Quick
     Qt6::QuickControls2
@@ -32,6 +36,7 @@ target_link_libraries(packagecompatibilityhelper_static PUBLIC
     KF6::CoreAddons
     KF6::KIOCore
     KF6::KIOWidgets
+    PkgConfig::Flatpak
 )
 target_include_directories(packagecompatibilityhelper_static PUBLIC ${CMAKE_BINARY_DIR})
 
@@ -39,6 +44,8 @@ target_include_directories(packagecompatibilityhelper_static PUBLIC ${CMAKE_BINA
 add_executable(package-compatibility-helper main.cpp)
 target_link_libraries(package-compatibility-helper PUBLIC packagecompatibilityhelper_static packagecompatibilityhelper_staticplugin)
 install(FILES app_db.json DESTINATION ${KDE_INSTALL_DATADIR}/${PROJECT_NAME})
+install(FILES ${CMAKE_SOURCE_DIR}/data/apps/wine.conf
+        DESTINATION ${KDE_INSTALL_DATADIR}/${PROJECT_NAME}/apps)
 install(TARGETS package-compatibility-helper ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
 
 install(FILES org.kde.package-compatibility-helper.desktop DESTINATION ${KDE_INSTALL_APPDIR})
diff --git a/src/CompatibilityHelperFactory.cpp b/src/CompatibilityHelperFactory.cpp
index c1a85e4..e06bc0f 100644
--- a/src/CompatibilityHelperFactory.cpp
+++ b/src/CompatibilityHelperFactory.cpp
@@ -2,7 +2,9 @@
 // SPDX-FileCopyrightText: 2025 Thomas Duckworth <[email protected]>
 
 #include "CompatibilityHelperFactory.h"
+#include "CompatibilityToolInstaller.h"
 #include "DebCompatibilityHelper.h"
+#include "GenericCompatibilityHelper.h"
 #include "ICompatibilityHelper.h"
 #include "RpmCompatibilityHelper.h"
 #include "WindowsCompatibilityHelper.h"
@@ -19,27 +21,38 @@ ICompatibilityHelper *CompatibilityHelperFactory::create(const QUrl &filePath)
     QMimeDatabase mimeDb;
     QString mimeTypeName = mimeDb.mimeTypeForFile(filePath.toLocalFile()).name();
 
+    CompatibilityToolInstaller *installer = CompatibilityToolInstaller::findForMimeType(mimeTypeName);
+    ICompatibilityHelper *helper = nullptr;
+
     if (mimeTypeName == u"application/x-ms-dos-executable"_s || mimeTypeName == u"application/x-msi"_s || mimeTypeName == u"application/x-ms-shortcut"_s
         || mimeTypeName == u"application/vnd.microsoft.portable-executable"_s || mimeTypeName == u"application/x-msdownload"_s) {
-        return createWindowsCompatibilityHelper(QUrl::fromLocalFile(WINDOWSCOMPATIBILITYHELPER_DB_PATH), filePath);
-    }
-
-    if (mimeTypeName == u"application/x-rpm"_s) {
-        return createRpmCompatibilityHelper(filePath);
-    }
-
-    if (mimeTypeName == u"application/vnd.debian.binary-package"_s || mimeTypeName == u"application-x-deb"_s) {
-        return createDebCompatibilityHelper(filePath);
+        helper = createWindowsCompatibilityHelper(QUrl::fromLocalFile(WINDOWSCOMPATIBILITYHELPER_DB_PATH), filePath);
+    } else if (mimeTypeName == u"application/x-rpm"_s) {
+        helper = createRpmCompatibilityHelper(filePath);
+    } else if (mimeTypeName == u"application/vnd.debian.binary-package"_s || mimeTypeName == u"application-x-deb"_s) {
+        helper = createDebCompatibilityHelper(filePath);
     }
 
     // TODO: Create an AppImage compatibility helper.
     /*if (mimeTypeName == u"application/vnd.appimage"_s || mimeTypeName == u"application/x-iso9660-appimage"_s) {
-        return createAppImageCompatibilityHelper(filePath);
+        helper = createAppImageCompatibilityHelper(filePath);
     }*/
 
     // This returns when no compatible helper was found for the given file type.
     // At this point, the program should exit.
-    return nullptr;
+    if (!helper && installer) {
+        helper = createGenericCompatibilityHelper(filePath);
+    }
+    if (!helper) {
+        return nullptr;
+    }
+
+    if (installer) {
+        installer->setParent(helper);
+    }
+    helper->setCompatibilityToolInstaller(installer);
+
+    return helper;
 }
 
 ICompatibilityHelper *CompatibilityHelperFactory::createWindowsCompatibilityHelper(const QUrl &databaseFilePath, const QUrl &openedExePath)
@@ -56,3 +69,8 @@ ICompatibilityHelper *CompatibilityHelperFactory::createDebCompatibilityHelper(c
 {
     return new DebCompatibilityHelper(filePath);
 }
+
+ICompatibilityHelper *CompatibilityHelperFactory::createGenericCompatibilityHelper(const QUrl &filePath)
+{
+    return new GenericCompatibilityHelper(filePath);
+}
diff --git a/src/CompatibilityHelperFactory.h b/src/CompatibilityHelperFactory.h
index fe54bb1..f7a38cf 100644
--- a/src/CompatibilityHelperFactory.h
+++ b/src/CompatibilityHelperFactory.h
@@ -16,4 +16,5 @@ private:
     static ICompatibilityHelper *createWindowsCompatibilityHelper(const QUrl &databaseFilePath, const QUrl &openedExePath);
     static ICompatibilityHelper *createRpmCompatibilityHelper(const QUrl &filePath);
     static ICompatibilityHelper *createDebCompatibilityHelper(const QUrl &filePath);
+    static ICompatibilityHelper *createGenericCompatibilityHelper(const QUrl &filePath);
 };
diff --git a/src/CompatibilityToolInstaller.cpp b/src/CompatibilityToolInstaller.cpp
new file mode 100644
index 0000000..47f2c1e
--- /dev/null
+++ b/src/CompatibilityToolInstaller.cpp
@@ -0,0 +1,616 @@
+// SPDX-License-Identifier: LGPL-2.0-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+#include "CompatibilityToolInstaller.h"
+#include "directories.h"
+
+#include <QCoreApplication>
+#include <QDebug>
+#include <QDir>
+#include <QEventLoop>
+#include <QFileInfo>
+#include <QIcon>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+#include <QProcess>
+#include <QSettings>
+#include <QStandardPaths>
+#include <QThread>
+#include <QUrl>
+
+#include <KFormat>
+#include <KLocalizedString>
+
+#include <flatpak/flatpak.h>
+
+#include <algorithm>
+
+using namespace Qt::Literals::StringLiterals;
+
+namespace
+{
+
+// Aggregates per-operation progress into a single 0-100 value.
+struct TransactionWatcher {
+    CompatibilityToolInstaller *installer = nullptr;
+    int totalOperations = 1;
+    int doneOperations = 0;
+};
+
+gboolean onTransactionReady(FlatpakTransaction *transaction, gpointer userData)
+{
+    auto *watcher = static_cast<TransactionWatcher *>(userData);
+    GList *operations = flatpak_transaction_get_operations(transaction);
+    watcher->totalOperations = std::max<int>(static_cast<int>(g_list_length(operations)), 1);
+    g_list_free_full(operations, g_object_unref);
+    return TRUE;
+}
+
+gboolean onAddNewRemote(FlatpakTransaction *, gint /*FlatpakTransactionRemoteReason*/, const char *, const char *, const char *, gpointer)
+{
+    // Allow flatpak to add dependency remotes, e.g. the runtime's origin.
+    return TRUE;
+}
+
+void onProgressChanged(FlatpakTransactionProgress *progress, gpointer userData)
+{
+    auto *watcher = static_cast<TransactionWatcher *>(userData);
+    const int current = flatpak_transaction_progress_get_progress(progress);
+    const int overall = std::min((watcher->doneOperations * 100 + current) / watcher->totalOperations, 100);
+    QMetaObject::invokeMethod(
+        watcher->installer,
+        [installer = watcher->installer, overall]() {
+            installer->updateProgress(overall);
+        },
+        Qt::QueuedConnection);
+}
+
+void onNewOperation(FlatpakTransaction *, FlatpakTransactionOperation *, FlatpakTransactionProgress *progress, gpointer userData)
+{
+    flatpak_transaction_progress_set_update_frequency(progress, 250);
+    g_signal_connect(progress, "changed", G_CALLBACK(onProgressChanged), userData);
+}
+
+void onOperationDone(FlatpakTransaction *, FlatpakTransactionOperation *, const char *, gint, gpointer userData)
+{
+    auto *watcher = static_cast<TransactionWatcher *>(userData);
+    watcher->doneOperations++;
+}
+
+// Worker thread only.
+QByteArray fetchUrl(const QUrl &url, QString *errorString)
+{
+    QNetworkAccessManager manager;
+    QNetworkRequest request(url);
+    request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
+
+    QEventLoop loop;
+    QNetworkReply *reply = manager.get(request);
+    QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
+    loop.exec();
+
+    reply->deleteLater();
+    if (reply->error() != QNetworkReply::NoError) {
+        *errorString = reply->errorString();
+        return {};
+    }
+    return reply->readAll();
+}
+
+bool installationHasApp(FlatpakInstallation *installation, const QByteArray &appId, QString *errorText)
+{
+    g_autoptr(GError) error = nullptr;
+    g_autoptr(GPtrArray) refs = flatpak_installation_list_installed_refs_by_kind(installation, FLATPAK_REF_KIND_APP, nullptr, &error);
+    if (!refs) {
+        if (errorText) {
+            *errorText = QString::fromUtf8(error ? error->message : "unknown error");
+        }
+        return false;
+    }
+
+    for (guint i = 0; i < refs->len; ++i) {
+        auto *ref = FLATPAK_REF(g_ptr_array_index(refs, i));
+        if (appId == flatpak_ref_get_name(ref)) {
+            return true;
+        }
+    }
+    return false;
+}
+
+} // namespace
+
+CompatibilityToolInstaller::CompatibilityToolInstaller(QObject *parent)
+    : QObject(parent)
+    , m_cancellable(g_cancellable_new())
+{
+}
+
+static QStringList configSearchDirs()
+{
+    return {COMPATIBILITY_TOOL_SYSCONF_CONFIG_DIR, COMPATIBILITY_TOOL_DATA_CONFIG_DIR};
+}
+
+CompatibilityToolInstaller *CompatibilityToolInstaller::loadConfigFile(const QString &path, QObject *parent)
+{
+    QSettings ini(path, QSettings::IniFormat);
+    auto *installer = new CompatibilityToolInstaller(parent);
+    installer->m_appId = ini.value(u"App/Id"_s).toString();
+    installer->m_displayName = ini.value(u"App/Name"_s, installer->m_appId).toString();
+    installer->m_icon = ini.value(u"App/Icon"_s, u"application-x-executable"_s).toString();
+    installer->m_mimeTypes = ini.value(u"App/MimeTypes"_s).toStringList();
+    installer->m_remoteName = ini.value(u"Remote/Name"_s).toString();
+    installer->m_remoteUrl = ini.value(u"Remote/Url"_s).toString();
+    installer->m_postInstall = ini.value(u"Install/PostInstall"_s).toString();
+    installer->m_takeOverMimeTypes = ini.value(u"Install/TakeOverMimeTypes"_s, false).toBool();
+
+    if (installer->m_appId.isEmpty() || installer->m_remoteName.isEmpty() || installer->m_remoteUrl.isEmpty()) {
+        delete installer;
+        return nullptr;
+    }
+    return installer;
+}
+
+CompatibilityToolInstaller *CompatibilityToolInstaller::load(const QString &name, QObject *parent)
+{
+    if (name.isEmpty() || name.contains(u'/') || name.startsWith(u'.')) {
+        return nullptr;
+    }
+    for (const QString &dir : configSearchDirs()) {
+        const QString candidate = dir + u'/' + name + u".conf"_s;
+        if (QFileInfo(candidate).isFile()) {
+            return loadConfigFile(candidate, parent);
+        }
+    }
+    return nullptr;
+}
+
+CompatibilityToolInstaller *CompatibilityToolInstaller::findForMimeType(const QString &mimeType, QObject *parent)
+{
+    if (mimeType.isEmpty()) {
+        return nullptr;
+    }
+
+    QStringList seenNames;
+    for (const QString &dir : configSearchDirs()) {
+        const QDir configDir(dir);
+        const QStringList entries = configDir.entryList({u"*.conf"_s}, QDir::Files, QDir::Name);
+        for (const QString &entry : entries) {
+            if (seenNames.contains(entry)) {
+                continue;
+            }
+            seenNames.append(entry);
+
+            CompatibilityToolInstaller *installer = loadConfigFile(configDir.filePath(entry), parent);
+            if (installer && installer->m_mimeTypes.contains(mimeType)) {
+                return installer;
+            }
+            delete installer;
+        }
+    }
+    return nullptr;
+}
+
+CompatibilityToolInstaller::~CompatibilityToolInstaller()
+{
+    g_cancellable_cancel(m_cancellable);
+    if (m_workThread) {
+        m_workThread->wait();
+    }
+    g_object_unref(m_cancellable);
+}
+
+QString CompatibilityToolInstaller::displayName() const
+{
+    return m_displayName;
+}
+
+QString CompatibilityToolInstaller::icon() const
+{
+    if (QIcon::hasThemeIcon(m_icon)) {
+        return m_icon;
+    }
+    return u"install-symbolic"_s;
+}
+
+QString CompatibilityToolInstaller::appId() const
+{
+    return m_appId;
+}
+
+QString CompatibilityToolInstaller::remoteName() const
+{
+    return m_remoteName;
+}
+
+int CompatibilityToolInstaller::progress() const
+{
+    return m_progress;
+}
+
+bool CompatibilityToolInstaller::sizesKnown() const
+{
+    return m_sizesKnown;
+}
+
+QString CompatibilityToolInstaller::downloadSizeText() const
+{
+    return m_sizesKnown ? KFormat().formatByteSize(m_downloadSize) : QString();
+}
+
+QString CompatibilityToolInstaller::installedSizeText() const
+{
+    return m_sizesKnown ? KFormat().formatByteSize(m_installedSize) : QString();
+}
+
+QString CompatibilityToolInstaller::errorText() const
+{
+    return m_errorText;
+}
+
+bool CompatibilityToolInstaller::cancelled() const
+{
+    return m_cancelled.load();
+}
+
+void CompatibilityToolInstaller::setErrorText(const QString &text)
+{
+    if (text != m_errorText) {
+        m_errorText = text;
+        Q_EMIT errorTextChanged();
+    }
+}
+
+void CompatibilityToolInstaller::updateProgress(int percent)
+{
+    if (percent != m_progress) {
+        m_progress = percent;
+        Q_EMIT progressChanged();
+    }
+}
+
+void CompatibilityToolInstaller::setResolvedRef(const QString &ref, quint64 downloadSize, quint64 installedSize)
+{
+    m_ref = ref;
+    m_downloadSize = downloadSize;
+    m_installedSize = installedSize;
+    m_sizesKnown = true;
+    Q_EMIT sizesChanged();
+}
+
+bool CompatibilityToolInstaller::isInstalled() const
+{
+    const QByteArray appId = m_appId.toUtf8();
+    g_autoptr(GError) userError = nullptr;
+    g_autoptr(FlatpakInstallation) userInstallation = flatpak_installation_new_user(nullptr, &userError);
+    if (userInstallation) {
+        QString errorText;
+        if (installationHasApp(userInstallation, appId, &errorText)) {
+            return true;
+        }
+        if (!errorText.isEmpty()) {
+            qWarning() << "Could not list user Flatpaks:" << errorText;
+        }
+    }
+
+    g_autoptr(GError) systemError = nullptr;
+    g_autoptr(GPtrArray) systemInstallations = flatpak_get_system_installations(nullptr, &systemError);
+    if (!systemInstallations) {
+        qWarning() << "Could not list system Flatpak installations:" << (systemError ? systemError->message : "unknown error");
+        return false;
+    }
+    for (guint i = 0; i < systemInstallations->len; ++i) {
+        auto *installation = FLATPAK_INSTALLATION(g_ptr_array_index(systemInstallations, i));
+        QString errorText;
+        if (installationHasApp(installation, appId, &errorText)) {
+            return true;
+        }
+        if (!errorText.isEmpty()) {
+            qWarning() << "Could not list system Flatpaks:" << errorText;
+        }
+    }
+
+    return false;
+}
+
+void CompatibilityToolInstaller::runPostInstall() const
+{
+    const QString exports = QDir::homePath() + u"/.local/share/flatpak/exports/share/applications/"_s;
+    QProcess::execute(u"update-desktop-database"_s, {exports});
+
+    if (!QStandardPaths::findExecutable(u"kbuildsycoca6"_s).isEmpty()) {
+        QProcess::execute(u"kbuildsycoca6"_s, {});
+    }
+
+    if (!m_postInstall.isEmpty()) {
+        QStringList argv = QProcess::splitCommand(m_postInstall);
+        if (!argv.isEmpty()) {
+            const QString program = argv.takeFirst();
+            QProcess::execute(program, argv);
+        }
+    }
+
+    takeOverMimeTypes();
+}
+
+void CompatibilityToolInstaller::takeOverMimeTypes() const
+{
+    if (!m_takeOverMimeTypes) {
+        return;
+    }
+
+    const QString desktopFile = m_appId + u".desktop"_s;
+    for (const QString &mimeType : m_mimeTypes) {
+        QProcess::execute(u"xdg-mime"_s, {u"default"_s, desktopFile, mimeType});
+    }
+}
+
+// Worker thread only.
+static bool ensureRemote(const QString &remoteName,
+                         const QString &remoteUrl,
+                         FlatpakInstallation *installation,
+                         GCancellable *cancellable,
+                         std::atomic<bool> &remoteAddedByUs,
+                         QString *errorString)
+{
+    const QByteArray remoteNameBytes = remoteName.toUtf8();
+
+    g_autoptr(FlatpakRemote) existing = flatpak_installation_get_remote_by_name(installation, remoteNameBytes.constData(), cancellable, nullptr);
+    if (existing) {
+        return true;
+    }
+
+    const QByteArray repoFile = fetchUrl(QUrl(remoteUrl), errorString);
+    if (repoFile.isEmpty()) {
+        return false;
+    }
+
+    g_autoptr(GBytes) bytes = g_bytes_new(repoFile.constData(), repoFile.size());
+    g_autoptr(GError) error = nullptr;
+    g_autoptr(FlatpakRemote) remote = flatpak_remote_new_from_file(remoteNameBytes.constData(), bytes, &error);
+    if (!remote) {
+        *errorString = QString::fromUtf8(error ? error->message : "invalid .flatpakrepo file");
+        return false;
+    }
+
+    if (!flatpak_installation_add_remote(installation, remote, TRUE /* if_needed */, cancellable, &error)) {
+        *errorString = QString::fromUtf8(error ? error->message : "could not add remote");
+        return false;
+    }
+
+    remoteAddedByUs = true;
+    return true;
+}
+
+// Worker thread only.
+static QString resolveRemoteRef(const QString &remoteName,
+                                const QString &appId,
+                                FlatpakInstallation *installation,
+                                GCancellable *cancellable,
+                                quint64 *downloadSize,
+                                quint64 *installedSize)
+{
+    const QByteArray remoteNameBytes = remoteName.toUtf8();
+    const QByteArray appIdBytes = appId.toUtf8();
+
+    g_autoptr(GError) error = nullptr;
+    g_autoptr(GPtrArray) refs = flatpak_installation_list_remote_refs_sync(installation, remoteNameBytes.constData(), cancellable, &error);
+    if (!refs) {
+        qWarning() << "Could not list refs of remote" << remoteName << ":" << (error ? error->message : "unknown error");
+        return QString();
+    }
+
+    FlatpakRemoteRef *match = nullptr;
+    for (guint i = 0; i < refs->len; ++i) {
+        auto *remoteRef = FLATPAK_REMOTE_REF(g_ptr_array_index(refs, i));
+        auto *ref = FLATPAK_REF(remoteRef);
+        if (flatpak_ref_get_kind(ref) != FLATPAK_REF_KIND_APP || appIdBytes != flatpak_ref_get_name(ref)) {
+            continue;
+        }
+        match = remoteRef;
+        if (g_strcmp0(flatpak_ref_get_arch(ref), flatpak_get_default_arch()) == 0) {
+            break;
+        }
+    }
+
+    if (!match) {
+        qWarning() << "Remote" << remoteName << "has no app named" << appId;
+        return QString();
+    }
+
+    *downloadSize = flatpak_remote_ref_get_download_size(match);
+    *installedSize = flatpak_remote_ref_get_installed_size(match);
+
+    g_autofree char *formatted = flatpak_ref_format_ref(FLATPAK_REF(match));
+    return QString::fromUtf8(formatted);
+}
+
+void CompatibilityToolInstaller::prepare()
+{
+    if (m_workThread || m_sizesKnown) {
+        return;
+    }
+
+    m_workThread = QThread::create([this]() {
+        g_autoptr(GError) error = nullptr;
+        g_autoptr(FlatpakInstallation) installation = flatpak_installation_new_user(m_cancellable, &error);
+        if (!installation) {
+            qWarning() << "Could not open the user Flatpak installation:" << (error ? error->message : "unknown error");
+            return;
+        }
+
+        QString errorString;
+        if (!ensureRemote(m_remoteName, m_remoteUrl, installation, m_cancellable, m_remoteAddedByUs, &errorString)) {
+            qWarning() << "Could not set up remote" << m_remoteName << ":" << errorString;
+            return;
+        }
+
+        quint64 downloadSize = 0;
+        quint64 installedSize = 0;
+        const QString ref = resolveRemoteRef(m_remoteName, m_appId, installation, m_cancellable, &downloadSize, &installedSize);
+        if (ref.isEmpty()) {
+            return;
+        }
+
+        QMetaObject::invokeMethod(
+            this,
+            [this, ref, downloadSize, installedSize]() {
+                setResolvedRef(ref, downloadSize, installedSize);
+            },
+            Qt::QueuedConnection);
+    });
+    connect(m_workThread, &QThread::finished, this, [this, thread = m_workThread]() {
+        thread->deleteLater();
+        if (m_workThread == thread) {
+            m_workThread = nullptr;
+        }
+    });
+    m_workThread->start();
+}
+
+void CompatibilityToolInstaller::start()
+{
+    if (m_workThread) {
+        m_workThread->wait();
+    }
+
+    if (m_cancelled.exchange(false)) {
+        Q_EMIT cancelledChanged();
+    }
+    g_cancellable_reset(m_cancellable);
+
+    m_workThread = QThread::create([this]() {
+        auto fail = [this](const QString &message) {
+            qWarning() << message;
+            QMetaObject::invokeMethod(
+                this,
+                [this, message]() {
+                    setErrorText(message);
+                },
+                Qt::QueuedConnection);
+            Q_EMIT finished(false);
+        };
+
+        g_autoptr(GError) error = nullptr;
+        g_autoptr(FlatpakInstallation) installation = flatpak_installation_new_user(m_cancellable, &error);
+        if (!installation) {
+            fail(i18n("Could not open the Flatpak installation: %1", QString::fromUtf8(error ? error->message : "unknown error")));
+            return;
+        }
+
+        QString errorString;
+        if (!ensureRemote(m_remoteName, m_remoteUrl, installation, m_cancellable, m_remoteAddedByUs, &errorString)) {
+            fail(i18n("Could not set up the remote \"%1\": %2", m_remoteName, errorString));
+            return;
+        }
+
+        QString ref = m_ref;
+        if (ref.isEmpty()) {
+            quint64 downloadSize = 0;
+            quint64 installedSize = 0;
+            ref = resolveRemoteRef(m_remoteName, m_appId, installation, m_cancellable, &downloadSize, &installedSize);
+        }
+        if (ref.isEmpty()) {
+            fail(i18n("The remote \"%1\" has no application named %2.", m_remoteName, m_appId));
+            return;
+        }
+
+        g_autoptr(FlatpakTransaction) transaction = flatpak_transaction_new_for_installation(installation, m_cancellable, &error);
+        if (!transaction) {
+            fail(i18n("Could not create a Flatpak transaction: %1", QString::fromUtf8(error ? error->message : "unknown error")));
+            return;
+        }
+        // Look for runtimes in the other configured installations too.
+        flatpak_transaction_add_default_dependency_sources(transaction);
+
+        const QByteArray remoteName = m_remoteName.toUtf8();
+        const QByteArray refBytes = ref.toUtf8();
+        if (!flatpak_transaction_add_install(transaction, remoteName.constData(), refBytes.constData(), nullptr, &error)) {
+            fail(i18n("Could not queue the installation of %1: %2", ref, QString::fromUtf8(error ? error->message : "unknown error")));
+            return;
+        }
+
+        TransactionWatcher watcher;
+        watcher.installer = this;
+        g_signal_connect(transaction, "ready", G_CALLBACK(onTransactionReady), &watcher);
+        g_signal_connect(transaction, "add-new-remote", G_CALLBACK(onAddNewRemote), &watcher);
+        g_signal_connect(transaction, "new-operation", G_CALLBACK(onNewOperation), &watcher);
+        g_signal_connect(transaction, "operation-done", G_CALLBACK(onOperationDone), &watcher);
+
+        const bool success = flatpak_transaction_run(transaction, m_cancellable, &error);
+        if (!success && !m_cancelled) {
+            fail(QString::fromUtf8(error ? error->message : "unknown error"));
+            return;
+        }
+        Q_EMIT finished(success && !m_cancelled);
+    });
+    connect(m_workThread, &QThread::finished, this, [this, thread = m_workThread]() {
+        thread->deleteLater();
+        if (m_workThread == thread) {
+            m_workThread = nullptr;
+        }
+    });
+    m_workThread->start();
+}
+
+void CompatibilityToolInstaller::cancel()
+{
+    if (!m_cancelled.exchange(true)) {
+        Q_EMIT cancelledChanged();
+    }
+    g_cancellable_cancel(m_cancellable);
+}
+
+void CompatibilityToolInstaller::discardPreparation()
+{
+    if (!m_remoteAddedByUs || isInstalled()) {
+        return;
+    }
+
+    g_autoptr(GError) error = nullptr;
+    g_autoptr(FlatpakInstallation) installation = flatpak_installation_new_user(nullptr, &error);
+    if (!installation) {
+        return;
+    }
+
+    const QByteArray remoteName = m_remoteName.toUtf8();
+    if (!flatpak_installation_remove_remote(installation, remoteName.constData(), nullptr, &error)) {
+        qWarning() << "Could not remove remote" << m_remoteName << "again:" << (error ? error->message : "unknown error");
+        return;
+    }
+    m_remoteAddedByUs = false;
+}
+
+void CompatibilityToolInstaller::exitWith(int code)
+{
+    m_exitHandled = true;
+    QCoreApplication::exit(code);
+}
+
+void CompatibilityToolInstaller::decline()
+{
+    discardPreparation();
+    exitWith(1);
+}
+
+void CompatibilityToolInstaller::completeSuccess()
+{
+    runPostInstall();
+    exitWith(0);
+}
+
+void CompatibilityToolInstaller::quitError()
+{
+    discardPreparation();
+    exitWith(1);
+}
+
+void CompatibilityToolInstaller::windowClosed()
+{
+    if (m_exitHandled) {
+        return;
+    }
+    cancel();
+    discardPreparation();
+    exitWith(1);
+}
diff --git a/src/CompatibilityToolInstaller.h b/src/CompatibilityToolInstaller.h
new file mode 100644
index 0000000..39f0382
--- /dev/null
+++ b/src/CompatibilityToolInstaller.h
@@ -0,0 +1,98 @@
+// SPDX-License-Identifier: LGPL-2.0-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+#pragma once
+
+#include <QObject>
+#include <QStringList>
+
+#include <atomic>
+
+typedef struct _GCancellable GCancellable;
+class QThread;
+
+class CompatibilityToolInstaller : public QObject
+{
+    Q_OBJECT
+
+    Q_PROPERTY(QString displayName READ displayName CONSTANT)
+    Q_PROPERTY(QString icon READ icon CONSTANT)
+    Q_PROPERTY(QString appId READ appId CONSTANT)
+    Q_PROPERTY(QString remoteName READ remoteName CONSTANT)
+    Q_PROPERTY(int progress READ progress NOTIFY progressChanged)
+    Q_PROPERTY(bool sizesKnown READ sizesKnown NOTIFY sizesChanged)
+    Q_PROPERTY(QString downloadSizeText READ downloadSizeText NOTIFY sizesChanged)
+    Q_PROPERTY(QString installedSizeText READ installedSizeText NOTIFY sizesChanged)
+    Q_PROPERTY(QString errorText READ errorText NOTIFY errorTextChanged)
+    Q_PROPERTY(bool cancelled READ cancelled NOTIFY cancelledChanged)
+
+public:
+    static CompatibilityToolInstaller *load(const QString &name, QObject *parent = nullptr);
+    static CompatibilityToolInstaller *findForMimeType(const QString &mimeType, QObject *parent = nullptr);
+
+    ~CompatibilityToolInstaller() override;
+
+    QString displayName() const;
+    QString icon() const;
+    QString appId() const;
+    QString remoteName() const;
+    int progress() const;
+    bool sizesKnown() const;
+    QString downloadSizeText() const;
+    QString installedSizeText() const;
+    QString errorText() const;
+    bool cancelled() const;
+
+    bool isInstalled() const;
+    void runPostInstall() const;
+    void takeOverMimeTypes() const;
+
+    // Adds the remote if needed and resolves the remote ref.
+    Q_INVOKABLE void prepare();
+    Q_INVOKABLE void start();
+    Q_INVOKABLE void cancel();
+    // Removes the remote again if prepare() added it.
+    Q_INVOKABLE void discardPreparation();
+
+    // Exit paths for the standalone --install mode.
+    Q_INVOKABLE void decline();
+    Q_INVOKABLE void completeSuccess();
+    Q_INVOKABLE void quitError();
+    Q_INVOKABLE void windowClosed();
+
+    void updateProgress(int percent);
+    void setResolvedRef(const QString &ref, quint64 downloadSize, quint64 installedSize);
+    void setErrorText(const QString &text);
+
+Q_SIGNALS:
+    void progressChanged();
+    void sizesChanged();
+    void errorTextChanged();
+    void cancelledChanged();
+    void finished(bool success);
+
+private:
+    explicit CompatibilityToolInstaller(QObject *parent = nullptr);
+    static CompatibilityToolInstaller *loadConfigFile(const QString &path, QObject *parent);
+    void exitWith(int code);
+
+    QString m_appId;
+    QString m_displayName;
+    QString m_icon;
+    QString m_remoteName;
+    QString m_remoteUrl;
+    QString m_postInstall;
+    QStringList m_mimeTypes;
+    bool m_takeOverMimeTypes = false;
+    QThread *m_workThread = nullptr;
+    GCancellable *m_cancellable = nullptr;
+    QString m_ref;
+    QString m_errorText;
+    quint64 m_downloadSize = 0;
+    quint64 m_installedSize = 0;
+    bool m_sizesKnown = false;
+    int m_progress = 0;
+    bool m_exitHandled = false;
+    std::atomic<bool> m_remoteAddedByUs = false;
+    std::atomic<bool> m_cancelled = false;
+};
diff --git a/src/DebCompatibilityHelper.h b/src/DebCompatibilityHelper.h
index b070224..91a0336 100644
--- a/src/DebCompatibilityHelper.h
+++ b/src/DebCompatibilityHelper.h
@@ -22,30 +22,8 @@ public:
     bool hasNativeApp() const override;
     QString nativeAppActionText() const override;
     QString nativeAppActionIcon() const override;
-    bool hasCompatibilityTool() const override
-    {
-        // Always false for this helper.
-        // Maybe at some point work out how to create a Distrobox for DEBs.
-        // However, this is probably a bit too complex for people who would be using this helper.
-        return false;
-    };
-    QString compatibilityToolActionText() const override
-    {
-        // Not implemented.
-        return QString();
-    }
-    QString compatibilityToolActionIcon() const override
-    {
-        // Not implemented.
-        return QString();
-    }
 
     Q_INVOKABLE void nativeAppAction() const override;
-    Q_INVOKABLE void compatibilityToolAction() const override
-    {
-        // Not implemented.
-        qWarning() << "Invalid operation: No compatibility tool action is available for RPM files.";
-    }
 
 private:
     QString m_nativeAppName;
@@ -61,10 +39,5 @@ private:
 
     QString nativeAppName() const override;
     QString nativeAppRef() const override;
-    bool isCompatibilityToolInstalled() const override
-    {
-        // Always false for this helper.
-        return false;
-    }
     bool isNativeAppInstalled() const override;
 };
diff --git a/src/GenericCompatibilityHelper.cpp b/src/GenericCompatibilityHelper.cpp
new file mode 100644
index 0000000..ffac750
--- /dev/null
+++ b/src/GenericCompatibilityHelper.cpp
@@ -0,0 +1,99 @@
+// SPDX-License-Identifier: LGPL-2.0-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+#include "GenericCompatibilityHelper.h"
+
+#include <KLocalizedString>
+#include <QMimeDatabase>
+#include <QMimeType>
+
+GenericCompatibilityHelper::GenericCompatibilityHelper(const QUrl &filePath, QObject *parent)
+    : ICompatibilityHelper(filePath, parent)
+{
+    if (m_filePath.isValid() && m_filePath.isLocalFile()) {
+        const QMimeType mimeType = QMimeDatabase().mimeTypeForFile(m_filePath.toLocalFile());
+        m_mimeComment = mimeType.comment();
+        if (hasIcon(mimeType.iconName())) {
+            m_mimeIcon = mimeType.iconName();
+        } else if (hasIcon(mimeType.genericIconName())) {
+            m_mimeIcon = mimeType.genericIconName();
+        }
+    }
+    if (m_mimeIcon.isEmpty()) {
+        m_mimeIcon = u"application-x-executable"_s;
+    }
+}
+
+QString GenericCompatibilityHelper::windowTitle() const
+{
+    // In --install mode there is no file, so use the tool's name.
+    if (installOnly()) {
+        return compatibilityToolName();
+    }
+    return m_filePath.fileName();
+}
+
+QString GenericCompatibilityHelper::heading() const
+{
+    if (!m_mimeComment.isEmpty()) {
+        return i18nc("@title %1 is a file type name like \"Java archive\", %2 is the distro name",
+                     "%1 files are not natively supported on %2",
+                     m_mimeComment,
+                     distroName());
+    }
+    return i18nc("@title %1 is the distro name", "This type of file is not natively supported on %1", distroName());
+}
+
+QString GenericCompatibilityHelper::icon() const
+{
+    return m_mimeIcon;
+}
+
+QString GenericCompatibilityHelper::description() const
+{
+    QString desc = i18n("You can search for alternatives online or in %1.", appStoreName());
+
+    if (hasCompatibilityTool()) {
+        desc += u"<br><br>"_s;
+        if (isCompatibilityToolInstalled()) {
+            desc += i18n("Alternatively, you can open this file with %1.", compatibilityToolName());
+        } else {
+            desc += i18n("Alternatively, you can install %1 to open this type of file.", compatibilityToolName());
+        }
+    } else {
+        desc += u"<br><br>"_s;
+        desc += i18n("Learn about options for getting it by clicking <b>Get Help</b> below.");
+    }
+
+    return desc;
+}
+
+bool GenericCompatibilityHelper::hasNativeApp() const
+{
+    return false;
+}
+
+QString GenericCompatibilityHelper::nativeAppActionText() const
+{
+    return QString();
+}
+
+QString GenericCompatibilityHelper::nativeAppActionIcon() const
+{
+    return QString();
+}
+
+QString GenericCompatibilityHelper::nativeAppName() const
+{
+    return QString();
+}
+
+QString GenericCompatibilityHelper::nativeAppRef() const
+{
+    return QString();
+}
+
+bool GenericCompatibilityHelper::isNativeAppInstalled() const
+{
+    return false;
+}
diff --git a/src/GenericCompatibilityHelper.h b/src/GenericCompatibilityHelper.h
new file mode 100644
index 0000000..9891a5a
--- /dev/null
+++ b/src/GenericCompatibilityHelper.h
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier: LGPL-2.0-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+#pragma once
+
+#include "ICompatibilityHelper.h"
+
+using namespace Qt::Literals::StringLiterals;
+
+// Used for file types that have no dedicated helper but do have a
+// compatibility tool config with a matching MimeTypes= entry.
+//
+// Also used (with an empty file path) as the backing object of
+// `package-compatibility-helper --install <name>`, where Main.qml goes
+// straight to the install page.
+class GenericCompatibilityHelper : public ICompatibilityHelper
+{
+    Q_OBJECT
+
+public:
+    explicit GenericCompatibilityHelper(const QUrl &filePath, QObject *parent = nullptr);
+    ~GenericCompatibilityHelper() override = default;
+
+    QString windowTitle() const override;
+    QString heading() const override;
+    QString icon() const override;
+    QString description() const override;
+    bool hasNativeApp() const override;
+    QString nativeAppActionText() const override;
+    QString nativeAppActionIcon() const override;
+
+private:
+    QString nativeAppName() const override;
+    QString nativeAppRef() const override;
+    bool isNativeAppInstalled() const override;
+
+    // The user-visible name of the file's MIME type, e.g. "Java archive".
+    QString m_mimeComment;
+    QString m_mimeIcon;
+};
diff --git a/src/ICompatibilityHelper.cpp b/src/ICompatibilityHelper.cpp
index 2b858fa..fbc1ac1 100644
--- a/src/ICompatibilityHelper.cpp
+++ b/src/ICompatibilityHelper.cpp
@@ -2,6 +2,7 @@
 // SPDX-FileCopyrightText: 2025 Thomas Duckworth <[email protected]>
 
 #include "ICompatibilityHelper.h"
+#include "CompatibilityToolInstaller.h"
 
 #include <KIO/ApplicationLauncherJob>
 #include <KIO/CommandLauncherJob>
@@ -124,8 +125,8 @@ QString ICompatibilityHelper::documentationActionText() const
     return i18n("Get Help");
 }
 
-// Default implementations for the pure virtual Q_INVOKABLEs in ICompatibilityHelper.
-// These should be overridden in subclasses to provide specific functionality.
+// Default implementation for the pure virtual Q_INVOKABLE in ICompatibilityHelper.
+// This should be overridden in subclasses to provide specific functionality.
 // This is to avoid linker errors as the MOC is not able to resolve these without default implementations.
 void ICompatibilityHelper::nativeAppAction() const
 {
@@ -133,8 +134,90 @@ void ICompatibilityHelper::nativeAppAction() const
     qWarning() << "Please file a bug report.";
 }
 
-void ICompatibilityHelper::compatibilityToolAction() const
+void ICompatibilityHelper::setCompatibilityToolInstaller(CompatibilityToolInstaller *installer)
 {
-    qWarning() << "compatibilityToolAction() was called on base class - this should always be overridden in subclasses.";
-    qWarning() << "Please file a bug report.";
+    m_compatibilityToolInstaller = installer;
+}
+
+bool ICompatibilityHelper::installOnly() const
+{
+    return m_installOnly;
+}
+
+void ICompatibilityHelper::setInstallOnly(bool installOnly)
+{
+    m_installOnly = installOnly;
+}
+
+bool ICompatibilityHelper::hasCompatibilityTool() const
+{
+    return m_compatibilityToolInstaller != nullptr;
+}
+
+QString ICompatibilityHelper::compatibilityToolName() const
+{
+    return m_compatibilityToolInstaller != nullptr ? m_compatibilityToolInstaller->displayName() : QString();
+}
+
+QObject *ICompatibilityHelper::compatibilityToolInstaller() const
+{
+    return m_compatibilityToolInstaller;
+}
+
+bool ICompatibilityHelper::isCompatibilityToolInstalled() const
+{
+    if (!m_compatibilityToolInstaller) {
+        return false;
+    }
+    return m_compatibilityToolInstaller->isInstalled();
+}
+
+bool ICompatibilityHelper::compatibilityToolInstalled() const
+{
+    return isCompatibilityToolInstalled();
+}
+
+QString ICompatibilityHelper::compatibilityToolActionText() const
+{
+    if (!hasCompatibilityTool()) {
+        return QString();
+    }
+    if (isCompatibilityToolInstalled()) {
+        return i18n("Run with %1", compatibilityToolName());
+    }
+    return i18n("Install %1", compatibilityToolName());
+}
+
+QString ICompatibilityHelper::compatibilityToolActionIcon() const
+{
+    if (!m_compatibilityToolInstaller) {
+        return QString();
+    }
+    if (isCompatibilityToolInstalled() && hasIcon(m_compatibilityToolInstaller->appId())) {
+        return m_compatibilityToolInstaller->appId();
+    }
+    return m_compatibilityToolInstaller->icon();
+}
+
+void ICompatibilityHelper::launchCompatibilityTool() const
+{
+    if (!hasCompatibilityTool()) {
+        qWarning() << "Invalid operation: No compatibility tool is available for this file type.";
+        return;
+    }
+    if (!isCompatibilityToolInstalled()) {
+        qWarning() << "Invalid operation: The compatibility tool is not installed.";
+        return;
+    }
+    m_compatibilityToolInstaller->takeOverMimeTypes();
+    openApp(m_compatibilityToolInstaller->appId(), {m_filePath});
+}
+
+void ICompatibilityHelper::compatibilityToolInstallFinished() const
+{
+    if (!m_compatibilityToolInstaller) {
+        return;
+    }
+    m_compatibilityToolInstaller->runPostInstall();
+    openApp(m_compatibilityToolInstaller->appId(), {m_filePath});
 }
diff --git a/src/ICompatibilityHelper.h b/src/ICompatibilityHelper.h
index 8c5a472..7295b4b 100644
--- a/src/ICompatibilityHelper.h
+++ b/src/ICompatibilityHelper.h
@@ -10,6 +10,8 @@
 
 using namespace Qt::Literals::StringLiterals;
 
+class CompatibilityToolInstaller;
+
 class ICompatibilityHelper : public QObject
 {
     Q_OBJECT
@@ -36,13 +38,17 @@ class ICompatibilityHelper : public QObject
     Q_PROPERTY(QString nativeAppActionIcon READ nativeAppActionIcon CONSTANT)
 
     // Indicates if a compatibility tool exists for the executable.
-    // e.g. Bottles for running .exe files, or Gear Lever for running AppImages.
-    // This would usually be set with a simple `return true/false` in the subclass.
+    // e.g. Wine for running .exe files, or Gear Lever for running AppImages.
     Q_PROPERTY(bool hasCompatibilityTool READ hasCompatibilityTool CONSTANT)
     // The text to show the user for the action to install or open the compatibility tool.
     Q_PROPERTY(QString compatibilityToolActionText READ compatibilityToolActionText CONSTANT)
     // The icon to show the user for the action to install or open the compatibility tool.
     Q_PROPERTY(QString compatibilityToolActionIcon READ compatibilityToolActionIcon CONSTANT)
+    // Indicates if the compatibility tool for the executable is installed.
+    Q_PROPERTY(bool compatibilityToolInstalled READ compatibilityToolInstalled CONSTANT)
+    // The Flatpak installer configuration for the compatibility tool.
+    Q_PROPERTY(QObject *compatibilityToolInstaller READ compatibilityToolInstaller CONSTANT)
+    Q_PROPERTY(bool installOnly READ installOnly CONSTANT)
 
     // The text to show the user for the action to open the documentation.
     Q_PROPERTY(QString documentationActionText READ documentationActionText CONSTANT)
@@ -64,16 +70,25 @@ public:
     virtual bool hasNativeApp() const = 0;
     virtual QString nativeAppActionText() const = 0;
     virtual QString nativeAppActionIcon() const = 0;
-    virtual bool hasCompatibilityTool() const = 0;
-    virtual QString compatibilityToolActionText() const = 0;
-    virtual QString compatibilityToolActionIcon() const = 0;
+    virtual bool hasCompatibilityTool() const;
+    virtual QString compatibilityToolActionText() const;
+    virtual QString compatibilityToolActionIcon() const;
+    bool compatibilityToolInstalled() const;
+    QObject *compatibilityToolInstaller() const;
     QString documentationActionText() const;
     QString documentationActionIcon() const;
 
+    void setCompatibilityToolInstaller(CompatibilityToolInstaller *installer);
+    bool installOnly() const;
+    void setInstallOnly(bool installOnly);
+
     // Opens the software store to install the native application, or opens the native application if it is already installed.
     Q_INVOKABLE virtual void nativeAppAction() const;
-    // Opens the package with the chosen compatibility tool, or prompts the user to install the compatibility tool if it is not installed.
-    Q_INVOKABLE virtual void compatibilityToolAction() const;
+    // Launches the compatibility tool, if it's installed. Otherwise we push the Flatpak install wizard to the pageStack,
+    // which is done in QML.
+    Q_INVOKABLE virtual void launchCompatibilityTool() const;
+    // Runs the post-install hook and opens the file with the tool.
+    Q_INVOKABLE void compatibilityToolInstallFinished() const;
     // Opens the documentation link.
     // This is a generic action, so it doesn't need to be overridden in subclasses.
     Q_INVOKABLE void documentationAction() const;
@@ -95,8 +110,9 @@ protected:
     virtual QString nativeAppRef() const = 0;
 
     // Indicates if the compatibility tool is already installed on the system.
-    // This doesn't need to be exposed to the QML interface, as it is only used internally to determine how to display the compatibility tool action.
-    virtual bool isCompatibilityToolInstalled() const = 0;
+    virtual bool isCompatibilityToolInstalled() const;
+
+    QString compatibilityToolName() const;
 
     // Returns the documentation URL, which can be overridden per each mime type.
     virtual QUrl documentationUrl() const;
@@ -124,4 +140,8 @@ protected:
 
     // The file path of the executable/package being opened.
     QUrl m_filePath;
+
+private:
+    CompatibilityToolInstaller *m_compatibilityToolInstaller = nullptr;
+    bool m_installOnly = false;
 };
diff --git a/src/RpmCompatibilityHelper.h b/src/RpmCompatibilityHelper.h
index 56f0e8c..077eae9 100644
--- a/src/RpmCompatibilityHelper.h
+++ b/src/RpmCompatibilityHelper.h
@@ -22,30 +22,8 @@ public:
     bool hasNativeApp() const override;
     QString nativeAppActionText() const override;
     QString nativeAppActionIcon() const override;
-    bool hasCompatibilityTool() const override
-    {
-        // Always false for this helper.
-        // Maybe at some point work out how to create a Distrobox for RPMs.
-        // However, this is probably a bit too complex for people who would be using this helper.
-        return false;
-    };
-    QString compatibilityToolActionText() const override
-    {
-        // Not implemented.
-        return QString();
-    }
-    QString compatibilityToolActionIcon() const override
-    {
-        // Not implemented.
-        return QString();
-    }
 
     Q_INVOKABLE void nativeAppAction() const override;
-    Q_INVOKABLE void compatibilityToolAction() const override
-    {
-        // Not implemented.
-        qWarning() << "Invalid operation: No compatibility tool action is available for RPM files.";
-    }
 
 private:
     QString m_nativeAppName;
@@ -61,10 +39,5 @@ private:
 
     QString nativeAppName() const override;
     QString nativeAppRef() const override;
-    bool isCompatibilityToolInstalled() const override
-    {
-        // Always false for this helper.
-        return false;
-    }
     bool isNativeAppInstalled() const override;
 };
diff --git a/src/WindowsCompatibilityHelper.cpp b/src/WindowsCompatibilityHelper.cpp
index 105acce..4c259a2 100644
--- a/src/WindowsCompatibilityHelper.cpp
+++ b/src/WindowsCompatibilityHelper.cpp
@@ -140,9 +140,9 @@ QString WindowsCompatibilityHelper::description() const
     if (hasCompatibilityTool() && !hasNativeApp()) {
         desc += u"<br><br>"_s;
         if (isCompatibilityToolInstalled()) {
-            desc += i18n("Alternatively, you can run the Windows version using Wine. ");
+            desc += i18n("Alternatively, you can run the Windows version using %1. ", compatibilityToolName());
         } else {
-            desc += i18n("Alternatively, you can install Wine to run Windows applications. ");
+            desc += i18n("Alternatively, you can install %1 to run Windows applications. ", compatibilityToolName());
         }
         desc += i18n(
             "This is not recommended for most users, as running Windows applications through a compatibility layer can have bugs, poor performance, and poor "
@@ -188,38 +188,3 @@ void WindowsCompatibilityHelper::nativeAppAction() const
         openAppInAppStore(nativeAppRef());
     }
 }
-
-bool WindowsCompatibilityHelper::isCompatibilityToolInstalled() const
-{
-    return isAppInstalled(WINE_ID);
-}
-
-QString WindowsCompatibilityHelper::compatibilityToolActionText() const
-{
-    // TODO: Make this compatibility tool agnostic.
-    if (isCompatibilityToolInstalled()) {
-        return i18n("Run with Wine");
-    } else {
-        return i18n("Install Wine");
-    }
-}
-
-QString WindowsCompatibilityHelper::compatibilityToolActionIcon() const
-{
-    // TODO: Make this compatibility tool agnostic.
-    if (isCompatibilityToolInstalled() && hasIcon(WINE_ID)) {
-        return WINE_ID;
-    } else {
-        return u"plasmadiscover"_s;
-    }
-}
-
-void WindowsCompatibilityHelper::compatibilityToolAction() const
-{
-    // TODO: Make this compatibility tool agnostic.
-    if (isCompatibilityToolInstalled()) {
-        openApp(WINE_ID, {m_filePath});
-    } else {
-        openAppInAppStore(WINE_ID);
-    }
-}
diff --git a/src/WindowsCompatibilityHelper.h b/src/WindowsCompatibilityHelper.h
index 1e26eb0..d80dee8 100644
--- a/src/WindowsCompatibilityHelper.h
+++ b/src/WindowsCompatibilityHelper.h
@@ -12,8 +12,6 @@
 
 using namespace Qt::Literals::StringLiterals;
 
-#define WINE_ID u"org.winehq.Wine"_s
-
 class WindowsCompatibilityHelper : public ICompatibilityHelper
 {
     Q_OBJECT
@@ -34,17 +32,8 @@ public:
     };
     QString nativeAppActionText() const override;
     QString nativeAppActionIcon() const override;
-    bool hasCompatibilityTool() const override
-    {
-        // Always true for this helper.
-        return true;
-    };
-
-    QString compatibilityToolActionText() const override;
-    QString compatibilityToolActionIcon() const override;
 
     Q_INVOKABLE void nativeAppAction() const override;
-    Q_INVOKABLE void compatibilityToolAction() const override;
 
 private:
     QString m_nativeAppName;
@@ -59,7 +48,6 @@ private:
     {
         return m_nativeAppRef;
     }
-    bool isCompatibilityToolInstalled() const override;
     bool isNativeAppInstalled() const override;
 
     bool m_hasNativeApp = false;
diff --git a/src/contents/ui/InstallPage.qml b/src/contents/ui/InstallPage.qml
new file mode 100644
index 0000000..2dc4028
--- /dev/null
+++ b/src/contents/ui/InstallPage.qml
@@ -0,0 +1,180 @@
+// SPDX-License-Identifier: LGPL-2.0-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+import QtQuick
+import QtQuick.Controls as QQC2
+import QtQuick.Layouts
+import org.kde.kirigami as Kirigami
+
+Kirigami.Page {
+    id: page
+
+    required property var installer
+    property bool prepareOnCompleted: false
+
+    // "confirm" | "progress" | "error" | "cancelled"
+    property string phase: "confirm"
+
+    signal declined()
+    signal succeeded()
+
+    padding: Kirigami.Units.largeSpacing
+    implicitWidth: Math.max(pageContent.implicitWidth, Kirigami.Units.gridUnit * 28) + padding * 2
+    implicitHeight: pageContent.implicitHeight + padding * 2
+
+    Component.onCompleted: {
+        if (page.prepareOnCompleted) {
+            page.installer.prepare()
+        }
+    }
+
+    Connections {
+        target: page.installer
+        function onFinished(success) {
+            if (success) {
+                page.succeeded()
+            } else if (page.installer.cancelled) {
+                page.phase = "cancelled"
+            } else {
+                page.phase = "error"
+            }
+        }
+    }
+
+    ColumnLayout {
+        id: pageContent
+        anchors.fill: parent
+        Layout.margins: Kirigami.Units.largeSpacing
+        spacing: Kirigami.Units.largeSpacing
+
+        RowLayout {
+            Layout.alignment: Qt.AlignLeft | Qt.AlignTop
+            Layout.fillWidth: true
+            Layout.margins: Kirigami.Units.largeSpacing
+
+            Kirigami.Icon {
+                Layout.rightMargin: Kirigami.Units.largeSpacing * 2
+                Layout.preferredWidth: Kirigami.Units.iconSizes.large * 2
+                Layout.preferredHeight: Kirigami.Units.iconSizes.large * 2
+                Layout.alignment: Qt.AlignCenter
+                source: page.phase === "error" ? "dialog-error" : page.installer.icon
+            }
+
+            ColumnLayout {
+                spacing: Kirigami.Units.largeSpacing
+                Layout.fillWidth: true
+
+                Kirigami.Heading {
+                    id: heading
+                    Layout.fillWidth: true
+                    Layout.maximumWidth: Kirigami.Units.gridUnit * 22
+                    wrapMode: Text.WordWrap
+                    text: {
+                        switch (page.phase) {
+                        case "progress":
+                            return i18nc("@title %1 is an application name", "Installing %1", page.installer.displayName)
+                        case "error":
+                            return i18nc("@title", "Installation Failed")
+                        case "cancelled":
+                            return i18nc("@title", "Installation Cancelled")
+                        default:
+                            return i18nc("@title %1 is an application name", "Install %1?", page.installer.displayName)
+                        }
+                    }
+                }
+
+                QQC2.Label {
+                    Layout.fillWidth: true
+                    Layout.maximumWidth: Kirigami.Units.gridUnit * 22
+                    wrapMode: Text.WordWrap
+                    text: {
+                        switch (page.phase) {
+                        case "progress":
+                            return i18nc("@info:progress %1 is an application name", "Downloading and installing %1…", page.installer.displayName)
+                        case "error":
+                            return page.installer.errorText !== ""
+                                   ? page.installer.errorText
+                                   : i18nc("@info %1 is an application name", "%1 could not be installed.", page.installer.displayName)
+                        case "cancelled":
+                            return i18nc("@info %1 is an application name", "The installation of %1 was cancelled.", page.installer.displayName)
+                        default:
+                            return i18nc("@info %1 is an application name", "This software requires %1, which is not installed.", page.installer.displayName)
+                        }
+                    }
+                }
+
+                QQC2.Label {
+                    visible: page.phase === "confirm"
+                    Layout.fillWidth: true
+                    Layout.maximumWidth: Kirigami.Units.gridUnit * 22
+                    wrapMode: Text.WordWrap
+                    opacity: 0.75
+                    text: page.installer.sizesKnown
+                          ? i18nc("@info %1 is a Flatpak application id, %2 a Flatpak remote name, %3 a download size, %4 a size on disk",
+                                  "The Flatpak %1 will be downloaded from “%2” (%3 to download, %4 on disk).",
+                                  page.installer.appId, page.installer.remoteName,
+                                  page.installer.downloadSizeText, page.installer.installedSizeText)
+                          : i18nc("@info %1 is a Flatpak application id, %2 a Flatpak remote name",
+                                  "The Flatpak %1 will be downloaded from “%2”. Determining its size…",
+                                  page.installer.appId, page.installer.remoteName)
+                }
+
+                QQC2.ProgressBar {
+                    visible: page.phase === "progress"
+                    from: 0
+                    to: 100
+                    value: page.installer.progress
+                    Layout.fillWidth: true
+                    Layout.minimumWidth: Kirigami.Units.gridUnit * 18
+                }
+            }
+        }
+
+        Item {
+            Layout.fillHeight: true
+        }
+
+        RowLayout {
+            Layout.alignment: Qt.AlignRight
+            Layout.fillWidth: true
+
+            QQC2.Button {
+                visible: page.phase === "confirm"
+                highlighted: true
+                icon.name: "download-symbolic"
+                text: i18nc("@action:button Start the installation", "Install")
+                onClicked: {
+                    page.phase = "progress"
+                    page.installer.start()
+                }
+            }
+
+            QQC2.Button {
+                visible: page.phase === "confirm"
+                icon.name: "dialog-cancel"
+                text: i18nc("@action:button Dismiss the installation prompt", "Cancel")
+                onClicked: {
+                    page.installer.discardPreparation()
+                    page.declined()
+                }
+            }
+
+            QQC2.Button {
+                visible: page.phase === "progress"
+                icon.name: "dialog-cancel"
+                text: i18nc("@action:button Abort the running installation", "Cancel")
+                onClicked: page.installer.cancel()
+            }
+
+            QQC2.Button {
+                visible: page.phase === "error" || page.phase === "cancelled"
+                icon.name: "dialog-close"
+                text: i18nc("@action:button Dismiss the error", "Close")
+                onClicked: {
+                    page.installer.discardPreparation()
+                    page.declined()
+                }
+            }
+        }
+    }
+}
diff --git a/src/contents/ui/Main.qml b/src/contents/ui/Main.qml
index 430d3c2..4797129 100644
--- a/src/contents/ui/Main.qml
+++ b/src/contents/ui/Main.qml
@@ -10,44 +10,83 @@ import org.kde.packagecompatibilityhelper
 Kirigami.ApplicationWindow {
     id: root
 
-    title: PackageCompatibilityHelper.windowTitle
+    readonly property real fixedWidth: Math.max(mainPage.implicitWidth, installPageMetrics.item?.implicitWidth ?? 0)
+    readonly property real fixedHeight: Math.max(mainPage.implicitHeight, installPageMetrics.item?.implicitHeight ?? 0) + headerSeparator.implicitHeight
 
+    title: PackageCompatibilityHelper.windowTitle
     flags: Qt.Dialog | Qt.WindowStaysOnTopHint
-
     controlsVisible: false
     pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.None
+    pageStack.defaultColumnWidth: fixedWidth
 
-    minimumWidth: pageStack.currentItem?.implicitWidth ?? 0
-    minimumHeight: pageStack.currentItem?.implicitHeight ?? 0
-    width: minimumWidth
-    height: minimumHeight
-    maximumWidth: width
-    maximumHeight: height
+    minimumWidth: fixedWidth
+    maximumWidth: fixedWidth
+    width: fixedWidth
+    minimumHeight: fixedHeight
+    maximumHeight: fixedHeight
+    height: fixedHeight
 
     header: Kirigami.Separator {
+        id: headerSeparator
         Layout.fillWidth: true
     }
 
+    Component {
+        id: installPageComponent
+
+        InstallPage {
+            installer: PackageCompatibilityHelper.compatibilityToolInstaller
+            onDeclined: {
+                if (PackageCompatibilityHelper.installOnly) {
+                    installer.decline()
+                } else {
+                    root.pageStack.pop()
+                }
+            }
+            onSucceeded: {
+                if (PackageCompatibilityHelper.installOnly) {
+                    installer.completeSuccess()
+                } else {
+                    PackageCompatibilityHelper.compatibilityToolInstallFinished()
+                    root.close()
+                }
+            }
+        }
+    }
+
+    Component {
+        id: installPageMetricsComponent
+
+        InstallPage {
+            installer: PackageCompatibilityHelper.compatibilityToolInstaller
+        }
+    }
+
+    // Measures the dimensions of the install page without triggering it.
+    Loader {
+        id: installPageMetrics
+        visible: false
+        sourceComponent: installPageMetricsComponent
+    }
+
     pageStack.initialPage: Kirigami.Page {
         id: mainPage
-
         padding: Kirigami.Units.largeSpacing
-
         implicitWidth: pageContent.implicitWidth + padding * 2
         implicitHeight: pageContent.implicitHeight + padding * 2
 
         ColumnLayout {
             id: pageContent
-            spacing: Kirigami.Units.smallSpacing
-            Layout.fillWidth: true
+            anchors.fill: parent
+            Layout.margins: Kirigami.Units.largeSpacing
+            spacing: Kirigami.Units.largeSpacing
 
             RowLayout {
                 Layout.alignment: Qt.AlignLeft | Qt.AlignTop
-                Layout.fillWidth: true
                 Layout.margins: Kirigami.Units.largeSpacing
+                Layout.fillWidth: true
 
                 Kirigami.Icon {
-                    id: icon
                     Layout.rightMargin: Kirigami.Units.largeSpacing * 2
                     Layout.preferredWidth: Kirigami.Units.iconSizes.large * 2
                     Layout.preferredHeight: Kirigami.Units.iconSizes.large * 2
@@ -75,8 +114,11 @@ Kirigami.ApplicationWindow {
                 }
             }
 
+            Item {
+                Layout.fillHeight: true
+            }
+
             RowLayout {
-                id: actionButtons
                 Layout.alignment: Qt.AlignRight
                 Layout.fillWidth: true
 
@@ -96,8 +138,12 @@ Kirigami.ApplicationWindow {
                     icon.name: PackageCompatibilityHelper.compatibilityToolActionIcon
                     text: PackageCompatibilityHelper.compatibilityToolActionText
                     onClicked: {
-                        PackageCompatibilityHelper.compatibilityToolAction()
-                        root.close()
+                        if (PackageCompatibilityHelper.compatibilityToolInstalled) {
+                            PackageCompatibilityHelper.launchCompatibilityTool()
+                            root.close()
+                        } else {
+                            root.pageStack.push(installPageComponent, { prepareOnCompleted: true })
+                        }
                     }
                 }
 
@@ -133,4 +179,10 @@ Kirigami.ApplicationWindow {
             }
         }
     }
+
+    Component.onCompleted: {
+        if (PackageCompatibilityHelper.installOnly) {
+            pageStack.replace(installPageComponent, { prepareOnCompleted: true })
+        }
+    }
 }
diff --git a/src/directories.h.in b/src/directories.h.in
index b4bc754..56b1e26 100644
--- a/src/directories.h.in
+++ b/src/directories.h.in
@@ -4,3 +4,6 @@
 #pragma once
 
 #define WINDOWSCOMPATIBILITYHELPER_DB_PATH u"@KDE_INSTALL_FULL_DATADIR@/@PROJECT_NAME@/app_db.json"_s
+// Configs in the sysconf dir override those in the data dir.
+#define COMPATIBILITY_TOOL_SYSCONF_CONFIG_DIR u"@KDE_INSTALL_FULL_SYSCONFDIR@/@PROJECT_NAME@/apps"_s
+#define COMPATIBILITY_TOOL_DATA_CONFIG_DIR u"@KDE_INSTALL_FULL_DATADIR@/@PROJECT_NAME@/apps"_s
diff --git a/src/main.cpp b/src/main.cpp
index 7bc5d01..6289c2c 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,12 +1,13 @@
 // SPDX-License-Identifier: LGPL-2.0-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
 // SPDX-FileCopyrightText: 2025 Thomas Duckworth <[email protected]>
 
-#include <QtGlobal>
 #include <QApplication>
+#include <QtGlobal>
 
 #include <QIcon>
 #include <QQmlApplicationEngine>
 #include <QQmlContext>
+#include <QQmlEngine>
 #include <QQuickStyle>
 #include <QUrl>
 
@@ -18,17 +19,60 @@
 #include <qcoreapplication.h>
 
 #include "CompatibilityHelperFactory.h"
+#include "CompatibilityToolInstaller.h"
+#include "GenericCompatibilityHelper.h"
 
 using namespace Qt::Literals::StringLiterals;
 
+// Handles `package-compatibility-helper --install <config-name>`.
+// Exit codes (package-compatibility-helper-run relies on these):
+// 0 the Flatpak is installed,
+// 1 the user declined or the installation failed, 2 bad usage or config.
+static int runGui(QApplication &app, ICompatibilityHelper *helper)
+{
+    QQmlApplicationEngine engine;
+    qmlRegisterSingletonInstance("org.kde.packagecompatibilityhelper", 1, 0, "PackageCompatibilityHelper", helper);
+    engine.rootContext()->setContextObject(new KLocalizedContext(&engine));
+    engine.loadFromModule("org.kde.packagecompatibilityhelper", u"Main");
+    return engine.rootObjects().isEmpty() ? 1 : app.exec();
+}
+
+static int runInstall(QApplication &app, const QStringList &args)
+{
+    if (args.size() != 3) {
+        qWarning() << "Usage: package-compatibility-helper --install <config-name>";
+        return 2;
+    }
+
+    auto *helper = new GenericCompatibilityHelper(QUrl(), &app);
+    helper->setInstallOnly(true);
+    auto *installer = CompatibilityToolInstaller::load(args.at(2), helper);
+    if (!installer) {
+        qWarning() << "No valid installer config named" << args.at(2) << "was found.";
+        return 2;
+    }
+
+    // Already installed, no GUI needed.
+    if (installer->isInstalled()) {
+        installer->runPostInstall();
+        return 0;
+    }
+
+    helper->setCompatibilityToolInstaller(installer);
+    return runGui(app, helper);
+}
+
 int main(int argc, char *argv[])
 {
     QApplication app(argc, argv);
 
+    const QStringList args = QCoreApplication::arguments();
+
     // Ensure there's actually something to run.
-    if (argc < 2) {
+    if (args.size() < 2) {
         qWarning() << "No executable file provided.";
         qWarning() << "Usage: package-compatibility-helper <path to file>";
+        qWarning() << "       package-compatibility-helper --install <config-name>";
         return -1;
     }
 
@@ -58,33 +102,18 @@ int main(int argc, char *argv[])
     KAboutData::setApplicationData(aboutData);
     QGuiApplication::setWindowIcon(QIcon::fromTheme(u"apper"_s));
 
-    QQmlApplicationEngine engine;
-
-    // Register the correct compatibility helper as a QML singleton.
-    QUrl filePath = QUrl::fromLocalFile(QString::fromLatin1(argv[1]));
-    qmlRegisterSingletonType<ICompatibilityHelper>("org.kde.packagecompatibilityhelper",
-                                                   1,
-                                                   0,
-                                                   "PackageCompatibilityHelper",
-                                                   [filePath](QQmlEngine *engine, QJSEngine *scriptEngine) -> QObject * {
-                                                       Q_UNUSED(engine)
-                                                       Q_UNUSED(scriptEngine)
-
-                                                       ICompatibilityHelper *helper = CompatibilityHelperFactory::create(filePath);
-                                                       if (!helper) {
-                                                           qWarning() << "No compatible helper found for the provided file type.";
-                                                           qWarning() << "The application will now exit.";
-                                                           QCoreApplication::quit();
-                                                       }
-                                                       return helper;
-                                                   });
-
-    engine.rootContext()->setContextObject(new KLocalizedContext(&engine));
-    engine.loadFromModule("org.kde.packagecompatibilityhelper", u"Main");
+    // Install mode, used by the package-compatibility-helper-run launcher.
+    if (args.at(1) == u"--install"_s) {
+        return runInstall(app, args);
+    }
 
-    if (engine.rootObjects().isEmpty()) {
+    const QUrl filePath = QUrl::fromLocalFile(QString::fromLocal8Bit(argv[1]));
+    ICompatibilityHelper *helper = CompatibilityHelperFactory::create(filePath);
+    if (!helper) {
+        qWarning() << "No compatible helper found for the provided file type.";
         return -1;
     }
+    helper->setParent(&app);
 
-    return app.exec();
+    return runGui(app, helper);
 }
diff --git a/src/org.kde.package-compatibility-helper.desktop b/src/org.kde.package-compatibility-helper.desktop
index b3cc214..4ff5041 100644
--- a/src/org.kde.package-compatibility-helper.desktop
+++ b/src/org.kde.package-compatibility-helper.desktop
@@ -4,10 +4,9 @@
 Name=Package Compatibility Helper
 Comment=Provides support for running or finding alternatives to certain package types on ublue-based distributions.
 Version=1.0
-Exec=package-compatibility-helper
+Exec=package-compatibility-helper %f
 Icon=apper
 Type=Application
 Terminal=false
 NoDisplay=true
-MimeType=application/x-ms-dos-executable;application/x-msi;application/x-ms-shortcut;application/vnd.microsoft.portable-executable;application/x-msdownload;application/x-rpm;application/vnd.debian.binary-package
-# TODO: These are not implemented yet: ;application/vnd.appimage;application/x-iso9660-appimage
\ No newline at end of file
+MimeType=application/x-ms-dos-executable;application/x-msi;application/x-ms-shortcut;application/vnd.microsoft.portable-executable;application/x-msdownload;application/x-rpm;application/vnd.debian.binary-package;application/java-archive;
diff --git a/tools/new-tree.py b/tools/new-tree.py
new file mode 100755
index 0000000..0b88711
--- /dev/null
+++ b/tools/new-tree.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+# SPDX-License-Identifier: LGPL-2.0-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+
+"""Scaffold a per-project integration tree under trees/.
+
+Files are written as *.in templates; the build substitutes
+@KDE_INSTALL_*@ macros so paths match the configured install dirs
+(e.g. libexecdir is /usr/lib on Arch).
+
+Example:
+    new-tree.py --name kjar \\
+        --app-id org.kde.kjar \\
+        --display-name "Java Support" \\
+        --icon application-x-java-archive \\
+        --remote-name kjar-nightly \\
+        --remote-url https://cdn.kde.org/flatpak/kjar-nightly/kjar-nightly.flatpakrepo \\
+        --post-install "flatpak run org.kde.kjar --generate-wrappers" \\
+        --take-over-mime-types \\
+        --cmd java --cmd javac \\
+        --mime application/java-archive \\
+        --binfmt jar --binfmt-cmd "java -jar" \\
+        [--output-dir trees]
+"""
+
+import argparse
+import sys
+from pathlib import Path
+
+# REUSE-IgnoreStart
+SPDX_HEADER: str = (
+    "# SPDX-License-Identifier: CC0-1.0\n"
+    "# SPDX-FileCopyrightText: NONE\n"
+)
+# REUSE-IgnoreEnd
+
+
+def parse_args(argv: list[str]) -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
+    )
+    parser.add_argument("--name", required=True, help="Tree name under trees/")
+    parser.add_argument("--app-id", required=True, help="Flatpak application ID")
+    parser.add_argument("--display-name", help="Human-readable name (defaults to --name)")
+    parser.add_argument("--icon", default="application-x-executable", help="Icon name")
+    parser.add_argument("--remote-name", required=True, help="Flatpak remote name")
+    parser.add_argument("--remote-url", required=True, help="Flatpak .flatpakrepo URL")
+    parser.add_argument("--post-install", help="Command run after a successful install")
+    parser.add_argument(
+        "--take-over-mime-types",
+        action="store_true",
+        help="Make the Flatpak the default handler for configured MIME types after installation",
+    )
+    parser.add_argument(
+        "--cmd",
+        action="append",
+        default=[],
+        dest="cmds",
+        metavar="CMD",
+        help="Command to shim into usr/bin (repeatable)",
+    )
+    parser.add_argument("--mime", help="MIME type to register a handler for")
+    parser.add_argument("--binfmt", metavar="EXT", help="File extension for a binfmt rule")
+    parser.add_argument("--binfmt-cmd", help="Interpreter command for the binfmt rule")
+    parser.add_argument("--output-dir", default="trees", help="Where to create the tree")
+    return parser.parse_args(argv)
+
+
+def write_file(path: Path, content: str, executable: bool = False) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(content, encoding="utf-8")
+    if executable:
+        path.chmod(0o755)
+
+
+def installer_config(args: argparse.Namespace, display_name: str) -> str:
+    lines: list[str] = [
+        SPDX_HEADER.rstrip("\n"),
+        "[App]",
+        f"Id={args.app_id}",
+        f"Name={display_name}",
+        f"Icon={args.icon}",
+    ]
+    if args.mime:
+        lines.append(f"MimeTypes={args.mime}")
+    lines += [
+        "",
+        "[Remote]",
+        f"Name={args.remote_name}",
+        f"Url={args.remote_url}",
+    ]
+    if args.post_install or args.take_over_mime_types:
+        lines += ["", "[Install]"]
+        if args.post_install:
+            lines.append(f"PostInstall={args.post_install}")
+        if args.take_over_mime_types:
+            lines.append("TakeOverMimeTypes=true")
+    return "\n".join(lines) + "\n"
+
+
+def command_shim(name: str, cmd: str) -> str:
+    return (
+        "#!/usr/bin/env bash\n"
+        f"{SPDX_HEADER}"
+        f'exec @KDE_INSTALL_FULL_LIBEXECDIR@/package-compatibility-helper-run {name} {cmd} "$@"\n'
+    )
+
+
+def desktop_entry(args: argparse.Namespace, display_name: str) -> str:
+    return (
+        f"{SPDX_HEADER}"
+        "[Desktop Entry]\n"
+        f"Name={display_name}\n"
+        f"Comment=Open this file type with {display_name}\n"
+        "Exec=@KDE_INSTALL_FULL_BINDIR@/package-compatibility-helper %f\n"
+        f"Icon={args.icon}\n"
+        "Terminal=false\n"
+        "Type=Application\n"
+        "NoDisplay=true\n"
+        f"MimeType={args.mime};\n"
+        "Categories=Development;\n"
+    )
+
+
+def binfmt_interpreter(args: argparse.Namespace) -> str:
+    return (
+        "#!/usr/bin/env bash\n"
+        f"{SPDX_HEADER}"
+        f'exec {args.binfmt_cmd} "$@"\n'
+    )
+
+
+def binfmt_rule(args: argparse.Namespace) -> str:
+    return (
+        f"{SPDX_HEADER}"
+        f":PackageCompatibilityHelper-{args.name}:E::{args.binfmt}::@KDE_INSTALL_FULL_LIBEXECDIR@/{args.name}-binfmt:F\n"
+    )
+
+
+def main(argv: list[str]) -> int:
+    args = parse_args(argv)
+
+    if args.binfmt and not args.binfmt_cmd:
+        print("--binfmt requires --binfmt-cmd", file=sys.stderr)
+        return 1
+
+    display_name: str = args.display_name or args.name
+    root = Path(args.output_dir) / args.name
+
+    write_file(
+        root / "usr/share/package-compatibility-helper/apps" / f"{args.name}.conf.in",
+        installer_config(args, display_name),
+    )
+
+    for cmd in args.cmds:
+        write_file(root / "usr/bin" / f"{cmd}.in", command_shim(args.name, cmd), executable=True)
+
+    if args.mime:
+        # Downstream trees may be added at runtime, after the main helper's
+        # desktop entry has been installed. Ship a dedicated MIME shim for
+        # those trees so matching files can still open the helper.
+        write_file(
+            root / "usr/share/applications" / f"package-compatibility-helper-{args.name}.desktop.in",
+            desktop_entry(args, display_name),
+        )
+
+    if args.binfmt:
+        write_file(
+            root / "usr/libexec" / f"{args.name}-binfmt.in",
+            binfmt_interpreter(args),
+            executable=True,
+        )
+        write_file(root / "usr/lib/binfmt.d" / f"{args.name}.conf.in", binfmt_rule(args))
+
+    print(f"Created tree: {root}")
+    print(f"Install it with -DPACKAGE_COMPATIBILITY_HELPER_TREES={args.name} (or 'all').")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))
diff --git a/trees/kjar/usr/bin/java.in b/trees/kjar/usr/bin/java.in
new file mode 100755
index 0000000..b8aca3d
--- /dev/null
+++ b/trees/kjar/usr/bin/java.in
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: NONE
+exec @KDE_INSTALL_FULL_LIBEXECDIR@/package-compatibility-helper-run kjar java "$@"
diff --git a/trees/kjar/usr/bin/javac.in b/trees/kjar/usr/bin/javac.in
new file mode 100755
index 0000000..36a5d23
--- /dev/null
+++ b/trees/kjar/usr/bin/javac.in
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: NONE
+exec @KDE_INSTALL_FULL_LIBEXECDIR@/package-compatibility-helper-run kjar javac "$@"
diff --git a/trees/kjar/usr/lib/binfmt.d/kjar.conf.in b/trees/kjar/usr/lib/binfmt.d/kjar.conf.in
new file mode 100644
index 0000000..4f0ae48
--- /dev/null
+++ b/trees/kjar/usr/lib/binfmt.d/kjar.conf.in
@@ -0,0 +1,3 @@
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: NONE
+:PackageCompatibilityHelper-kjar:E::jar::@KDE_INSTALL_FULL_LIBEXECDIR@/kjar-binfmt:F
diff --git a/trees/kjar/usr/libexec/kjar-binfmt.in b/trees/kjar/usr/libexec/kjar-binfmt.in
new file mode 100755
index 0000000..168a1d4
--- /dev/null
+++ b/trees/kjar/usr/libexec/kjar-binfmt.in
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: NONE
+exec @KDE_INSTALL_FULL_BINDIR@/java -jar "$@"
diff --git a/trees/kjar/usr/share/package-compatibility-helper/apps/kjar.conf.in b/trees/kjar/usr/share/package-compatibility-helper/apps/kjar.conf.in
new file mode 100644
index 0000000..047e549
--- /dev/null
+++ b/trees/kjar/usr/share/package-compatibility-helper/apps/kjar.conf.in
@@ -0,0 +1,15 @@
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: NONE
+[App]
+Id=org.kde.kjar
+Name=Java Support
+Icon=application-x-java-archive
+MimeTypes=application/java-archive
+
+[Remote]
+Name=kjar-nightly
+Url=https://cdn.kde.org/flatpak/kjar-nightly/kjar-nightly.flatpakrepo
+
+[Install]
+PostInstall=flatpak run org.kde.kjar --generate-wrappers
+TakeOverMimeTypes=true
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.