[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