diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bd9ac79 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{cpp,hpp,txt,cmake,json,qrc,ui,ts,yml}] +indent_style = space +indent_size = 4 + +[*.{yml,qrc,ui}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8006a15..ca2f362 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Static Analysis +name: Build, Test and Static Analysis on: push: @@ -7,11 +7,13 @@ on: pull_request: branches: [ "main" ] +permissions: + contents: read + jobs: - validate-and-package: + ubuntu-apt: + name: Ubuntu (apt) runs-on: ubuntu-24.04 - permissions: - contents: write steps: - name: Checkout Source Code @@ -24,16 +26,77 @@ jobs: sudo apt-get update sudo apt-get install -y \ build-essential cmake \ - qt6-base-dev qt6-tools-dev qt6-tools-dev-tools \ + qt6-base-dev qt6-tools-dev qt6-tools-dev-tools qt6-l10n-tools \ libqt6widgets6 \ - cppcheck \ - dpkg-dev + cppcheck + + - name: Build + run: | + cmake -B build -DCMAKE_BUILD_TYPE=Release -DUNIBACKPACK_WERROR=ON + cmake --build build --parallel + - name: Run Tests + run: ctest --test-dir build --output-on-failure - name: Run cppcheck Static Analysis run: | cppcheck --enable=warning,style,performance,portability \ + --library=qt \ --suppress=shadowVariable \ --error-exitcode=1 \ - src/ + -I include \ + src/ include/ tests/ + + arch-pacman: + name: Arch Linux (pacman) + runs-on: ubuntu-24.04 + container: archlinux:latest + + steps: + - name: Install Build Dependencies + run: pacman -Syu --noconfirm --needed base-devel cmake qt6-base qt6-tools + + - name: Checkout Source Code + uses: actions/checkout@v7 + + - name: Build + run: | + cmake -B build -DCMAKE_BUILD_TYPE=Release -DUNIBACKPACK_WERROR=ON + cmake --build build --parallel + - name: Run Tests + run: ctest --test-dir build --output-on-failure + + - name: Check Install Layout + run: | + DESTDIR="$PWD/pkg" cmake --install build --prefix /usr + test -x pkg/usr/bin/unibackpack + test -f pkg/usr/share/applications/unibackpack.desktop + test -f pkg/usr/share/icons/hicolor/512x512/apps/unibackpack.png + + docs: + name: Documentation (Doxygen) + runs-on: ubuntu-24.04 + # Ubuntu's Doxygen is older than the Doxyfile and warns about its tags + container: archlinux:latest + + steps: + - name: Install Doxygen + run: pacman -Syu --noconfirm --needed doxygen + + - name: Checkout Source Code + uses: actions/checkout@v7 + + # Fails on any warning, except for parameters and return values without their own + # @param/@return: most functions are documented in one line that names them with @p + - name: Generate Documentation + run: | + ( cat Doxyfile + echo "WARN_AS_ERROR = FAIL_ON_WARNINGS" + echo "WARN_NO_PARAMDOC = NO" ) | doxygen - + + - name: Upload Documentation + uses: actions/upload-artifact@v7 + with: + name: docs-html + path: docs/html diff --git a/.gitignore b/.gitignore index 8998933..f830c34 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ .vscode/ # Executable -UniBackpack *.deb # Qt files diff --git a/CMakeLists.txt b/CMakeLists.txt index cc9b119..a779856 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,18 @@ project(unibackpack VERSION 0.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) -find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network LinguistTools) +option(BUILD_TESTING "Build the unit tests" ON) +option(UNIBACKPACK_WERROR "Treat compiler warnings as errors" OFF) + +# Applied to every target defined below; Qt's headers are system includes and stay quiet +if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + add_compile_options(-Wall -Wextra -Wpedantic) + if(UNIBACKPACK_WERROR) + add_compile_options(-Werror) + endif() +endif() + +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets LinguistTools) set(CMAKE_AUTOMOC ON) # Handles Signals/Slots set(CMAKE_AUTORCC ON) # Handles .qrc resource files @@ -12,40 +23,79 @@ set(CMAKE_AUTOUIC ON) # Handles .ui files set(CMAKE_AUTOUIC_SEARCH_PATHS ui) -qt_add_executable(unibackpack - # Source files - src/main.cpp +include(GNUInstallDirs) + +# Everything except the widgets, so the tests can link against it +qt_add_library(unibackpack_core STATIC src/Downloader.cpp - src/MainWindow.cpp - src/utils/applyTranslator.cpp + src/PackageManager.cpp + src/utils/language.cpp src/utils/loadUniversities.cpp + src/utils/packageList.cpp - # Header files - include/MainWindow.hpp include/Downloader.hpp - include/utils/applyTranslator.hpp + include/PackageManager.hpp + include/utils/language.hpp include/utils/loadUniversities.hpp + include/utils/packageList.hpp +) - # UI - ui/MainWindow.ui +target_include_directories(unibackpack_core PUBLIC include) +target_link_libraries(unibackpack_core PUBLIC Qt6::Core) - # Resources +qt_add_executable(unibackpack + src/main.cpp + src/MainWindow.cpp + include/MainWindow.hpp + ui/MainWindow.ui resources/resources.qrc ) -target_include_directories(unibackpack PRIVATE include) - target_link_libraries(unibackpack PRIVATE - Qt6::Core + unibackpack_core Qt6::Gui Qt6::Widgets - Qt6::Network ) +# Every package list under resources/lists/ is embedded as :/lists///packages.json +file(GLOB_RECURSE PACKAGE_LISTS CONFIGURE_DEPENDS + RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} + resources/lists/*.json) +qt_add_resources(unibackpack package_lists + PREFIX "/" + BASE resources + FILES ${PACKAGE_LISTS}) + +# Strings from the core library are translated too, so lupdate scans its sources as well +get_target_property(CORE_SOURCES unibackpack_core SOURCES) qt_add_translations(unibackpack TS_FILES translations/unibackpack_en.ts translations/unibackpack_el.ts + SOURCES + ${CORE_SOURCES} + src/main.cpp + src/MainWindow.cpp + include/MainWindow.hpp + ui/MainWindow.ui + INCLUDE_DIRECTORIES + include ) -install(TARGETS unibackpack DESTINATION bin) +install(TARGETS unibackpack RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +install(FILES resources/unibackpack.desktop + DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) +install(FILES resources/icons/unibackpack.png + DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/512x512/apps) + +if(BUILD_TESTING) + enable_testing() + find_package(Qt6 REQUIRED COMPONENTS Test) + + qt_add_executable(tst_package_manager tests/tst_package_manager.cpp) + target_link_libraries(tst_package_manager PRIVATE unibackpack_core Qt6::Test) + # The tests also check the bundled data files, read from the source tree + target_compile_definitions(tst_package_manager PRIVATE + UNIBACKPACK_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + add_test(NAME tst_package_manager COMMAND tst_package_manager) +endif() diff --git a/PKGBUILD b/PKGBUILD index 4130602..46890fd 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -29,17 +29,10 @@ build() { cmake --build build } +check() { + ctest --test-dir build --output-on-failure +} + package() { DESTDIR="$pkgdir" cmake --install build - - install -Dm644 "$srcdir/$_pkgname/resources/unibackpack.desktop" \ - "$pkgdir/usr/share/applications/unibackpack.desktop" - - if [ -f "$srcdir/$_pkgname/resources/icons/unibackpack.png" ]; then - install -Dm644 "$srcdir/$_pkgname/resources/icons/unibackpack.png" \ - "$pkgdir/usr/share/pixmaps/unibackpack.png" - else - sed -i 's/^Icon=.*/Icon=system-software-install/' \ - "$pkgdir/usr/share/applications/unibackpack.desktop" - fi } diff --git a/README.md b/README.md index f6924ac..e4c2c18 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,185 @@ # UniBackpack -UniBackpack is a lightweight C++ Qt application designed to automate the installation of essential software and development tools for university students. By selecting the target University and Department, the application automatically detects the underlying Linux distribution's package manager (`apt` or `pacman`) and installs the required toolchain. +UniBackpack is a lightweight C++ Qt application that installs the software and development tools a university student needs for their studies. You pick your university and department, and UniBackpack detects your Linux distribution's package manager (`pacman` or `apt`) and installs that department's toolchain in one go. -## Prerequisites +It ships with UniOS, and runs on any Arch- or Debian-based distribution. -Before building UniBackpack, ensure that the following dependencies are installed on your system: +## How it works -* A C++17 compatible compiler (`gcc` or `clang`) -* CMake (version 3.10 or higher) -* Qt6 Development Libraries -* Polkit (`pkexec`) for granting installation privileges +1. The app lists the universities, then the departments of the one you pick. Departments that have no package list for your package manager yet are greyed out. +2. When you pick a department, UniBackpack checks which of its packages exist in your repositories and reports the ones that don't. +3. It installs the available ones through `pkexec`, so you are asked for your password once, and shows the progress and the package manager's output. With `apt` it refreshes the package lists first. With `pacman` it asks you to update your system yourself beforehand, since Arch doesn't support partial upgrades. +4. Until the package manager starts, you can cancel the run or close the window. After that, the window stays open until the install finishes, because interrupting `apt` or `pacman` can break the package database. -### Installing Dependencies +The interface is available in English and Greek, selectable from the status bar. -**For Arch Linux / Manjaro / EndeavourOS:** +## Installation + +### As a user + +**On UniOS**, UniBackpack is preinstalled. If you removed it, install it again from `unios-repo`: + +```bash +sudo pacman -S unibackpack +``` + +**On other Arch-based distributions**, build the package from the `PKGBUILD` in this repository: + +```bash +git clone https://github.com/open-source-uom/UniBackpack.git +cd UniBackpack +makepkg -si +``` + +**On Debian-based distributions**, build and install it from source as described below. + +### As a developer + +Requirements: + +* A C++17 compiler (`gcc` or `clang`) +* CMake 3.16 or newer +* Qt 6 (Core, Gui, Widgets, LinguistTools and Test) +* Polkit (`pkexec`), to run the installs as root + +Install them on **Arch Linux / UniOS / Manjaro / EndeavourOS**: ```bash sudo pacman -S base-devel cmake qt6-base qt6-tools polkit ``` -**For Debian / Ubuntu / Linux Mint:** +or on **Debian / Ubuntu / Linux Mint**: + +```bash +sudo apt install build-essential cmake qt6-base-dev qt6-tools-dev qt6-tools-dev-tools qt6-l10n-tools pkexec +``` + +Then build, run the tests and start the app: + +```bash +cmake -B build +cmake --build build +ctest --test-dir build --output-on-failure +./build/unibackpack +``` + +To install it system-wide, including the desktop entry and icon: + ```bash -sudo apt install build-essential cmake qt6-base-dev qt6-tools-dev qt6-tools-dev-tools policykit-1 +sudo cmake --install build ``` +Every push and pull request is built and tested on both Ubuntu (apt) and Arch Linux (pacman) by GitHub Actions, with compiler warnings treated as errors. To build the same way locally, add `-DUNIBACKPACK_WERROR=ON` to the first `cmake` command. The workflow also runs `cppcheck` and generates the API documentation with `doxygen`, failing on any warning; each run offers the result for download as the `docs-html` artifact. To generate it yourself, run `doxygen` from the repository root; it is written to `docs/`. + ## Project Structure -* `src/` - C++ source files (`.cpp`) -* `include/` - C++ header files (`.hpp`) -* `ui/` - Qt Designer forms (`.ui`) -* `resources/lists/` - Configuration files containing the software lists tailored for each academic department. -* `resources/icons/` - Icons for each academic department. +``` +UniBackpack/ +├── CMakeLists.txt build, install and test configuration +├── PKGBUILD Arch package recipe +├── Doxyfile API documentation settings +├── include/ headers (.hpp) +│ ├── Downloader.hpp runs the repository check and the install +│ ├── MainWindow.hpp the application window +│ ├── PackageManager.hpp pacman and apt backends +│ └── utils/ +│ ├── language.hpp supported languages and switching +│ ├── loadUniversities.hpp reads universities.json +│ └── packageList.hpp reads a department's packages.json +├── src/ sources (.cpp), same layout as include/ +├── ui/ +│ └── MainWindow.ui Qt Designer form +├── resources/ +│ ├── universities.json universities and departments, with Greek names +│ ├── lists/ +│ │ └── //packages.json +│ ├── icons/ university logos and the app icon +│ ├── resources.qrc icons and universities.json +│ └── unibackpack.desktop desktop entry +├── translations/ Qt Linguist files (unibackpack_el.ts, unibackpack_en.ts) +├── tests/ +│ └── tst_package_manager.cpp +└── .github/workflows/build.yml +``` + +All non-UI code is built as the `unibackpack_core` library, so the tests can link against it. The package lists are embedded into the binary automatically at build time; there is no need to list them in `resources.qrc`. + +### Data files + +`resources/universities.json` lists the universities and their departments. The English `name` is also the directory name under `resources/lists/`, and `translations` holds the names shown in other languages: + +```json +[ + { + "name": "University of Western Macedonia", + "translations": { "el": "Πανεπιστήμιο Δυτικής Μακεδονίας" }, + "icon": ":/icons/uowm_logo.png", + "departments": [ + { "name": "Informatics", "translations": { "el": "Τμήμα Πληροφορικής" } } + ] + } +] +``` + +`resources/lists///packages.json` lists the software for one department, with its package name for each package manager. Leave a package manager out when the software isn't packaged for it: + +```json +[ + { "name": "Java JDK", "apt": "default-jdk", "pacman": "jdk-openjdk" }, + { "name": "MariaDB", "apt": "mariadb-server", "pacman": "mariadb" }, + { "name": "FreeCAD", "pacman": "freecad" } +] +``` + +### Files the app writes + +UniBackpack only stores the language you picked, through `QSettings`, in `~/.config/UniBackpack/UniBackpack.conf`: +```ini +[General] +language=el_GR +``` + +Without it, the app follows the system language. + +## Adding a Department + +1. Add the department to its university in `resources/universities.json`, with an `"el"` translation of its name. +2. Create `resources/lists///packages.json`, using the exact English names from `universities.json` for the directories. +3. Run the tests. They check that every list parses, that every entry has at least one package, and that every list matches a department in `universities.json`. + +## Adding a Translation + +1. Add the language to `Utils::supportedLanguages()` in `src/utils/language.cpp`. +2. Add `translations/unibackpack_.ts` to `qt_add_translations` in `CMakeLists.txt`. +3. Build the `update_translations` target to fill it with the source strings, translate them with Qt Linguist, and add a `translations` entry for that language to every university and department in `universities.json`. + +## Roadmap + +Help is welcome on: + +* Package lists for the departments that don't have one yet. Only UoWM Informatics has one so far. +* `apt` package names for FreeCAD and Wireshark in the UoWM Informatics list. +* A backend for `dnf` (Fedora). +* A small installer helper with its own polkit policy, so the password prompt names it instead of `/usr/bin/env`. +* A `.deb` package for Debian-based distributions. + +## Recent Changes + +* The install logic moved into separate `pacman` and `apt` backends behind a `PackageManager` interface, with unit tests for their output parsing. +* The two per-department text lists (`pacman_list.txt`, `apt_list.txt`) were merged into one `packages.json`. +* University and department names are translated in `universities.json` instead of the `.ts` files. +* The repository check is now a single non-blocking call, and failures always report a reason instead of leaving the window disabled. +* With `apt`, the package lists are refreshed before installing, and an install can no longer hang on a configuration file prompt. +* A Cancel button stops a run until the package manager starts. +* CI builds and tests on both Ubuntu and Arch Linux, with compiler warnings as errors, and checks the API documentation with Doxygen. ## Contributors +Maintained by Apostolos Chalis and Ioannis Michadasis , with contributions from: + ---- \ No newline at end of file +UniBackpack is part of the [UniOS project](https://unios-project.eu). See the [UniOS docs](https://docs.unios-project.eu) for the general development workflow. diff --git a/include/Downloader.hpp b/include/Downloader.hpp index 2c3e7f7..4cb742a 100644 --- a/include/Downloader.hpp +++ b/include/Downloader.hpp @@ -1,36 +1,112 @@ // Author: Apostolos Chalis 2026 +/** + * @file Downloader.hpp + * @brief Checks and installs a department's package list. + */ #ifndef DOWNLOADER_HPP #define DOWNLOADER_HPP +#include "PackageManager.hpp" + +#include #include +#include #include -#include #include +#include + +class QProcess; + +/** + * @brief Installs a department's package list with the given package manager. + * + * start() checks which listed packages are in the repositories and then installs + * them through pkexec. Everything after start() runs asynchronously, and + * finished() is emitted exactly once at the end, including on failure. + */ class Downloader : public QObject { - Q_OBJECT + Q_OBJECT + +public: + /** + * @brief Creates a downloader for one department. + * + * @param university University name, as used in the @c :/lists/ resource path. + * @param department Department name, as used in the @c :/lists/ resource path. + * @param manager Backend to check and install with. Must not be null. It keeps + * progress state, so only one Downloader may use it at a time. + * @param parent Optional QObject parent. + */ + Downloader(const QString &university, const QString &department, + std::shared_ptr manager, QObject *parent = nullptr); + + /** + * @brief Starts the check, then the install. Returns immediately. + * + * If the department has no packages for this package manager, finished() + * is emitted before this function returns. + */ + void start(); + + /** + * @brief Stops the run if it is still safe to, and finishes it as cancelled. + * + * Possible while checking the packages and while pkexec waits for + * authorization. Once the package manager runs as root it can no longer + * be stopped, and stopping it could leave the package database broken, + * so the install then continues and cancellable_changed(false) is emitted. + */ + void cancel(); + +signals: + /** + * @brief Whether cancel() can still stop the run. It starts out true. + */ + void cancellable_changed(bool cancellable); + + /** + * @brief Overall progress of the install, from 0 to 99. + */ + void progress_updated(int percentage); + + /** + * @brief Text for the detailed log: complete lines of package manager output or own messages. + */ + void log_message(const QString &message); + + /** + * @brief A short, user-facing description of the current step. + */ + void status_changed(const QString &message); - private: - QString name_of_university; - QString name_of_department; + /** + * @brief Emitted once when the run ends. + * + * @param success true if the package manager finished with exit code 0. + * @param error Translated reason for the failure; empty on success. + */ + void finished(bool success, const QString &error); - public: - explicit Downloader(const QString &university, const QString &department, QObject *parent = nullptr) - : QObject(parent), name_of_university(university), name_of_department(department) {} +private: + void check_packages(const QHash &software_by_package); + void install(const QStringList &packages); - QString check_package_manager(); - QStringList read_package_list(bool standard_package_manager, QString package_manager); - bool is_in_pacman_repo(const QString &package_name); - bool is_in_apt_repo(const QString &package_name); + void mark_package_manager_running(); + void handle_install_output(const QByteArray &data); + void flush_install_output(); + void connect_start_failure(QProcess *process); - void download_via_pacman(const QStringList &list_to_be_downloaded); - void download_via_apt(const QStringList &list_to_be_downloaded); + void report(const QString &message); + void fail(const QString &message); - signals: - void progress_updated(int percentage); - void status_message(const QString &message); - void status_update(const QString &message); - void download_completed(bool success); + QString name_of_university; + QString name_of_department; + std::shared_ptr package_manager; + QString pending_output; ///< Install output after the last complete line + QPointer current_process; ///< The query or install process, while it runs + bool cancel_requested = false; + bool package_manager_running = false; ///< The install printed output, so it runs as root }; #endif // DOWNLOADER_HPP diff --git a/include/MainWindow.hpp b/include/MainWindow.hpp index ab4bdb3..c5a5efb 100644 --- a/include/MainWindow.hpp +++ b/include/MainWindow.hpp @@ -1,48 +1,102 @@ // Author: Apostolos Chalis 2026 // Co-Author: Ioannis Michadasis +/** + * @file MainWindow.hpp + * @brief Main application window. + */ #ifndef MAINWINDOW_HPP #define MAINWINDOW_HPP +#include "utils/loadUniversities.hpp" + +#include #include -#include -#include -#include -#include -#include +#include +#include + +#include +#include -void applyTranslator(const QString &locale); +class Downloader; +class PackageManager; +class QModelIndex; +class QStandardItemModel; QT_BEGIN_NAMESPACE namespace Ui { class MainWindow; } QT_END_NAMESPACE +/** + * @brief The application window. + * + * Lists the universities, then the selected university's departments, and + * installs the chosen department's packages with a Downloader while showing + * progress and the package manager's output. + */ class MainWindow : public QMainWindow { - Q_OBJECT - - public: - MainWindow(QWidget *parent = nullptr); - ~MainWindow(); - void retranslate(); - - protected: - void changeEvent(QEvent *event) override; - - private slots: - void on_university_selection(const QModelIndex &index); - void toggle_output(); - - private: - Ui::MainWindow *ui; - - QHash departments_by_university; - - QStandardItemModel *university_model; - QStandardItemModel *department_model; - - bool showing_universities; - bool output_visible = false; - QString current_university; + Q_OBJECT + +public: + /** + * @brief Builds the UI, loads the universities from @c :/universities.json + * and detects the system's package manager. + */ + explicit MainWindow(QWidget *parent = nullptr); + ~MainWindow() override; + + /** + * @brief Re-applies translated texts that are not part of the .ui form. + * + * Covers the university and department names, the status line, the + * progress bar text and the details button. + */ + void retranslate(); + +protected: + /** + * @brief Retranslates the whole window on QEvent::LanguageChange. + */ + void changeEvent(QEvent *event) override; + + /** + * @brief Keeps the window open once the package manager is installing. + */ + void closeEvent(QCloseEvent *event) override; + +private slots: + void handle_item_clicked(const QModelIndex &index); + void toggle_output(); + +private: + enum class InstallState { Idle, Running, Succeeded, Failed }; + + void setup_language_selector(); + void show_universities(); + void show_departments(const QString &university_name); + void start_install(const QString &department); + bool confirm_install(); + void finish_install(bool success, const QString &error); + void set_status(std::function text); + const Utils::University *find_university(const QString &name) const; + + std::unique_ptr ui; + + QList universities; + QStandardItemModel *university_model = nullptr; + QStandardItemModel *department_model = nullptr; + + /// Detected once at startup; null if the system has no supported package manager. + std::shared_ptr package_manager; + QPointer downloader; + InstallState install_state = InstallState::Idle; + + /// Produces the status line in the current language; empty while no status is shown. + std::function status_text; + + bool showing_universities = true; + bool output_visible = false; + QString current_university; }; #endif // MAINWINDOW_HPP diff --git a/include/PackageManager.hpp b/include/PackageManager.hpp new file mode 100644 index 0000000..7162f7e --- /dev/null +++ b/include/PackageManager.hpp @@ -0,0 +1,138 @@ +// Authors: Apostolos Chalis 2026 , Ioannis Michadasis 2026 +/** + * @file PackageManager.hpp + * @brief Backends for the system package managers UniBackpack can install with. + */ +#ifndef PACKAGE_MANAGER_HPP +#define PACKAGE_MANAGER_HPP + +#include +#include +#include +#include + +#include +#include + +/** + * @brief Everything that differs between the supported system package managers. + * + * Downloader drives the check/install flow and asks the backend for the + * commands to run and how to interpret their output. All output given to a + * backend comes from processes run with @c LC_ALL=C, so it is untranslated. + */ +class PackageManager { + Q_DECLARE_TR_FUNCTIONS(PackageManager) + +public: + virtual ~PackageManager() = default; + + /** + * @brief Returns a backend for the first supported package manager on PATH. + * + * pacman is preferred over apt when both are present. + * + * @return The backend, or nullptr if no supported package manager is installed. + */ + static std::unique_ptr detect(); + + /** + * @brief Command name of the package manager, e.g. "pacman". + * + * Also the key for this manager's package names in a department's package list. + */ + virtual QString name() const = 0; + + /** + * @brief Program that prints repository info for several packages in a single run. + */ + virtual QString query_program() const = 0; + + /** + * @brief Arguments for query_program() that look up all of @p packages. + */ + virtual QStringList query_arguments(const QStringList &packages) const = 0; + + /** + * @brief Extracts the available package names from the query output. + * + * @param query_output Standard output of query_program(). + * @return Names of the packages found in the repositories. + */ + virtual QSet parse_available(const QString &query_output) const = 0; + + /** + * @brief Command line, program first, that installs @p packages. It is run through pkexec. + */ + virtual QStringList install_command(const QStringList &packages) const = 0; + + /** + * @brief Extra @c NAME=value variables for the install command. Empty by default. + * + * pkexec clears the environment, so these are passed through @c env. + */ + virtual QStringList install_environment() const; + + /** + * @brief Resets progress-parsing state before an install of @p package_count packages. + */ + virtual void begin_install(int package_count); + + /** + * @brief Parses overall install progress from one line of install output. + * + * @param line A complete line of standard output, without the line break. + * @return Progress from 0 to 99, or std::nullopt if the line carries no progress + * information. 100 is left for the caller once the install has finished. + */ + virtual std::optional parse_progress(const QString &line) = 0; + + /** + * @brief Message to show the user before installing. Empty by default. + */ + virtual QString pre_install_warning() const; +}; + +/** + * @brief Backend for Arch-based systems (pacman). + * + * Downloads fill the first half of the progress bar and installs the second, + * both counted against the "Packages (N)" total that pacman prints. + */ +class PacmanBackend : public PackageManager { +public: + QString name() const override; + QString query_program() const override; + QStringList query_arguments(const QStringList &packages) const override; + QSet parse_available(const QString &query_output) const override; + QStringList install_command(const QStringList &packages) const override; + void begin_install(int package_count) override; + std::optional parse_progress(const QString &line) override; + QString pre_install_warning() const override; + +private: + int total = 0; + int downloaded = 0; + int installed = 0; +}; + +/** + * @brief Backend for Debian-based systems (apt-get). + * + * Refreshes the package lists before installing. Progress comes from the + * machine-readable status lines that apt-get writes + * when run with @c APT::Status-Fd=1: downloads fill the first half of the bar + * and dpkg the second. + */ +class AptBackend : public PackageManager { +public: + QString name() const override; + QString query_program() const override; + QStringList query_arguments(const QStringList &packages) const override; + QSet parse_available(const QString &query_output) const override; + QStringList install_command(const QStringList &packages) const override; + QStringList install_environment() const override; + std::optional parse_progress(const QString &line) override; +}; + +#endif // PACKAGE_MANAGER_HPP diff --git a/include/utils/applyTranslator.hpp b/include/utils/applyTranslator.hpp deleted file mode 100644 index 5aeeced..0000000 --- a/include/utils/applyTranslator.hpp +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @file applyTranslator.hpp - * @brief Runtime language switching for the application. - */ -#ifndef UTILS_TRANSLATOR_HPP -#define UTILS_TRANSLATOR_HPP - -#include - -namespace Utils { - /** - * @brief Switches the application's UI language to @p locale. - * - * Only Greek is currently translated. If @p locale starts with @c "el", - * the Greek translator is loaded from @c :/i18n/unibackpack_el.qm and installed. - * The file is loaded once and cached for later calls. - * Any other locale removes the Greek translator, so the original English strings are shown. - * - * If the @c .qm file is missing or empty, nothing is installed and the UI stays in English. - * - * Installing or removing a translator makes Qt send QEvent::LanguageChange - * to the application, and widgets then refresh their texts in @c changeEvent(). - * - * @param locale Locale code as stored in QSettings under "language", - * e.g. "en_US" or "el_GR". - * - * @pre A QCoreApplication instance must exist. - * @note Not thread-safe; call it from the GUI thread only. - */ - void applyTranslator(const QString &locale); -} - -#endif // UTILS_TRANSLATOR_HPP diff --git a/include/utils/language.hpp b/include/utils/language.hpp new file mode 100644 index 0000000..bdb58b4 --- /dev/null +++ b/include/utils/language.hpp @@ -0,0 +1,62 @@ +/** + * @file language.hpp + * @brief The supported UI languages and runtime language switching. + */ +#ifndef UTILS_LANGUAGE_HPP +#define UTILS_LANGUAGE_HPP + +#include +#include + +namespace Utils { + /** + * @brief A language the UI can be shown in. + */ + struct Language { + /// Locale code stored in QSettings, e.g. "el_GR". + QString code; + /// The language's name in that language, e.g. "Ελληνικά". + QString native_name; + }; + + /** + * @brief The languages offered in the language selector, English first. + * + * Each one except English needs a translations/unibackpack_\.ts file. + */ + QList supportedLanguages(); + + /** + * @brief Switches the application's UI language to @p locale. + * + * Loads :/i18n/unibackpack_\.qm for the best match of @p locale + * and installs it, replacing the translator installed by a previous call. + * If no matching or non-empty @c .qm file exists, no translator is installed, + * so the original English strings are shown. Qt's own translations, used for + * example by standard dialog buttons, are installed the same way when the + * system has them. + * + * Installing or removing a translator makes Qt send QEvent::LanguageChange + * to the application, and widgets then refresh their texts in @c changeEvent(). + * + * @param locale Locale code such as "en_US" or "el_GR". + * + * @pre A QCoreApplication instance must exist. + * @note Not thread-safe; call it from the GUI thread only. + */ + void applyTranslator(const QString &locale); + + /** + * @brief The language the user picked, or the system locale if they never picked one. + * + * @return Locale code such as "el_GR", read from QSettings under "language". + */ + QString savedLanguage(); + + /** + * @brief Stores @p locale as the user's language for savedLanguage(). + */ + void saveLanguage(const QString &locale); +} + +#endif // UTILS_LANGUAGE_HPP diff --git a/include/utils/loadUniversities.hpp b/include/utils/loadUniversities.hpp index 51a3e88..6e0e4d9 100644 --- a/include/utils/loadUniversities.hpp +++ b/include/utils/loadUniversities.hpp @@ -1,13 +1,67 @@ +/** + * @file loadUniversities.hpp + * @brief Loads the university/department catalogue from a JSON resource. + */ #ifndef LOAD_UNIVERSITIES_HPP #define LOAD_UNIVERSITIES_HPP +#include +#include #include -#include +/// @brief Free helper functions shared across the UI. namespace Utils { - void loadUniversities(const QString &path, - QHash &departments_by_university, - QStandardItemModel *university_model); + /** + * @brief A named catalogue entry with optional translated display names. + */ + struct CatalogEntry { + /// English name. Also the directory name under @c :/lists/, so it never changes with the language. + QString name; + /// Display names keyed by language code, e.g. "el". + QHash translations; + + /** + * @brief Name to show for @p locale, falling back to the English name. + * + * @param locale Locale code such as "el_GR". Only the language part is used. + */ + QString display_name(const QString &locale) const; + }; + + /** + * @brief A university, its logo and its departments. + */ + struct University : CatalogEntry { + /// Resource path of the university logo. + QString icon; + /// Departments in display order. + QList departments; + }; + + /** + * @brief Parses the universities JSON. + * + * The file must contain a JSON array of objects of the form + * @code{.json} + * [ + * { + * "name": "Aristotle University of Thessaloniki", + * "translations": { "el": "Αριστοτέλειο Πανεπιστήμιο Θεσσαλονίκης" }, + * "icon": ":/icons/auth_logo.png", + * "departments": [ + * { "name": "Informatics", "translations": { "el": "Τμήμα Πληροφορικής" } } + * ] + * } + * ] + * @endcode + * @c translations is optional everywhere. Universities and departments with an + * empty or missing @c name are skipped. + * + * @param path Path to the JSON file, usually a Qt resource such as ":/universities.json". + * @return The universities in file order. Empty, with a logged warning, if the file + * cannot be opened or is not a valid JSON array. + */ + QList loadUniversities(const QString &path); } #endif // LOAD_UNIVERSITIES_HPP diff --git a/include/utils/packageList.hpp b/include/utils/packageList.hpp new file mode 100644 index 0000000..6f01b32 --- /dev/null +++ b/include/utils/packageList.hpp @@ -0,0 +1,55 @@ +/** + * @file packageList.hpp + * @brief Loads a department's package list from a JSON resource. + */ +#ifndef PACKAGE_LIST_HPP +#define PACKAGE_LIST_HPP + +#include +#include +#include + +namespace Utils { + /** + * @brief One piece of software and its package name in each package manager. + */ + struct PackageEntry { + /// Human-readable name, e.g. "Java JDK". + QString name; + /// Package names keyed by PackageManager::name(), e.g. "apt" -> "default-jdk". + QHash packages; + }; + + /** + * @brief Resource path of a department's package list. + * + * @return :/lists/\/\/packages.json + */ + QString packageListPath(const QString &university, const QString &department); + + /** + * @brief Parses a package list. + * + * The file must contain a JSON array of objects of the form + * @code{.json} + * [ + * { "name": "Java JDK", "apt": "default-jdk", "pacman": "jdk-openjdk" }, + * { "name": "FreeCAD", "pacman": "freecad" } + * ] + * @endcode + * Every key other than @c name is a package manager. Leave a manager out when + * the software is not packaged for it. Entries without a @c name are skipped. + * + * @param path Path to the JSON file, usually from packageListPath(). + * @return The entries in file order. Empty, with a logged warning, if the file + * cannot be opened or is not a valid JSON array. + */ + QList loadPackageList(const QString &path); + + /** + * @brief Whether any entry of @p entries has a package for @p package_manager. + */ + bool hasPackagesFor(const QList &entries, const QString &package_manager); +} + +#endif // PACKAGE_LIST_HPP diff --git a/resources/lists/University of Western Macedonia/Informatics/apt_list.txt b/resources/lists/University of Western Macedonia/Informatics/apt_list.txt deleted file mode 100644 index bf50fa4..0000000 --- a/resources/lists/University of Western Macedonia/Informatics/apt_list.txt +++ /dev/null @@ -1,21 +0,0 @@ -bash -wget -gnupg -arduino -bison -build-essential -dbeaver-ce -default-jdk -flex -gcc -ghdl -gnuplot -gtkwave -kicad -libarmadillo-dev -mariadb-server -nodejs -npm -octave -simulide -weka diff --git a/resources/lists/University of Western Macedonia/Informatics/packages.json b/resources/lists/University of Western Macedonia/Informatics/packages.json new file mode 100644 index 0000000..0f44d61 --- /dev/null +++ b/resources/lists/University of Western Macedonia/Informatics/packages.json @@ -0,0 +1,25 @@ +[ + {"name": "Bash", "apt": "bash", "pacman": "bash"}, + {"name": "Wget", "apt": "wget", "pacman": "wget"}, + {"name": "GnuPG", "apt": "gnupg", "pacman": "gnupg"}, + {"name": "Arduino IDE", "apt": "arduino", "pacman": "arduino"}, + {"name": "Bison", "apt": "bison", "pacman": "bison"}, + {"name": "Build tools", "apt": "build-essential", "pacman": "base-devel"}, + {"name": "DBeaver", "apt": "dbeaver-ce", "pacman": "dbeaver"}, + {"name": "Java JDK", "apt": "default-jdk", "pacman": "jdk-openjdk"}, + {"name": "Flex", "apt": "flex", "pacman": "flex"}, + {"name": "FreeCAD", "pacman": "freecad"}, + {"name": "GCC", "apt": "gcc", "pacman": "gcc"}, + {"name": "GHDL", "apt": "ghdl", "pacman": "ghdl"}, + {"name": "gnuplot", "apt": "gnuplot", "pacman": "gnuplot"}, + {"name": "GTKWave", "apt": "gtkwave", "pacman": "gtkwave"}, + {"name": "KiCad", "apt": "kicad", "pacman": "kicad"}, + {"name": "Armadillo", "apt": "libarmadillo-dev", "pacman": "armadillo"}, + {"name": "MariaDB", "apt": "mariadb-server", "pacman": "mariadb"}, + {"name": "Node.js", "apt": "nodejs", "pacman": "nodejs"}, + {"name": "npm", "apt": "npm", "pacman": "npm"}, + {"name": "GNU Octave", "apt": "octave", "pacman": "octave"}, + {"name": "SimulIDE", "apt": "simulide", "pacman": "simulide"}, + {"name": "Weka", "apt": "weka", "pacman": "weka"}, + {"name": "Wireshark", "pacman": "wireshark-qt"} +] diff --git a/resources/lists/University of Western Macedonia/Informatics/pacman_list.txt b/resources/lists/University of Western Macedonia/Informatics/pacman_list.txt deleted file mode 100644 index 45c01f1..0000000 --- a/resources/lists/University of Western Macedonia/Informatics/pacman_list.txt +++ /dev/null @@ -1,23 +0,0 @@ -bash -wget -gnupg -arduino -bison -base-devel -dbeaver -jdk-openjdk -flex -freecad -gcc -ghdl -gnuplot -gtkwave -kicad -armadillo -mariadb -nodejs -npm -octave -simulide -weka -wireshark-qt diff --git a/resources/resources.qrc b/resources/resources.qrc index 3a7c5f6..d289e99 100644 --- a/resources/resources.qrc +++ b/resources/resources.qrc @@ -11,9 +11,5 @@ universities.json - - lists/University of Western Macedonia/Informatics/pacman_list.txt - lists/University of Western Macedonia/Informatics/apt_list.txt - diff --git a/resources/universities.json b/resources/universities.json index 698b95a..2985455 100644 --- a/resources/universities.json +++ b/resources/universities.json @@ -1,17 +1,29 @@ [ { "name": "Aristotle University of Thessaloniki", + "translations": { "el": "Αριστοτέλειο Πανεπιστήμιο Θεσσαλονίκης" }, "icon": ":/icons/auth_logo.png", - "departments": ["Informatics", "Physics"] + "departments": [ + { "name": "Informatics", "translations": { "el": "Τμήμα Πληροφορικής" } }, + { "name": "Physics", "translations": { "el": "Τμήμα Φυσικής" } } + ] }, { "name": "University of Western Macedonia", + "translations": { "el": "Πανεπιστήμιο Δυτικής Μακεδονίας" }, "icon": ":/icons/uowm_logo.png", - "departments": ["Informatics", "Mechanical Engineering"] + "departments": [ + { "name": "Informatics", "translations": { "el": "Τμήμα Πληροφορικής" } }, + { "name": "Mechanical Engineering", "translations": { "el": "Τμήμα Μηχανολογίας" } } + ] }, { "name": "University of Macedonia", + "translations": { "el": "Πανεπιστήμιο Μακεδονίας" }, "icon": ":/icons/uom_logo.png", - "departments": ["Applied Informatics", "Economics"] + "departments": [ + { "name": "Applied Informatics", "translations": { "el": "Τμήμα Εφαρμοσμένης Πληροφορικής" } }, + { "name": "Economics", "translations": { "el": "Τμήμα Οικονομικών" } } + ] } ] diff --git a/src/Downloader.cpp b/src/Downloader.cpp index c37915d..b03bd72 100644 --- a/src/Downloader.cpp +++ b/src/Downloader.cpp @@ -1,285 +1,207 @@ // Authors: Apostolos Chalis 2026 , Ioannis Michadasis 2026 #include "Downloader.hpp" -// test +#include "utils/packageList.hpp" + #include -#include -#include -#include -#include +#include #include -#include -#include -QString Downloader::check_package_manager() { - if (!QStandardPaths::findExecutable("pacman").isEmpty()) { - qDebug() << "Pacman found."; - return "pacman"; - } else if (!QStandardPaths::findExecutable("apt").isEmpty()) { - qDebug() << "Apt found."; - return "apt"; - } else { - qDebug() << "No supported package manager found."; - return "Unsupported"; +namespace { + QString describe_exit(int exit_code) { + // Exit codes reserved by pkexec, see pkexec(1) + switch (exit_code) { + case 126: + return Downloader::tr("Authorization was cancelled."); + case 127: + return Downloader::tr("You are not authorized to install packages."); + default: + return Downloader::tr("The package manager failed with exit code %1.").arg(exit_code); + } } } -bool Downloader::is_in_pacman_repo(const QString &package_name) { - QProcess process; - process.start("pacman", QStringList() << "-Si" << package_name); - process.waitForFinished(); - if (process.exitCode() == 0) { - QString msg = tr("Found: %1").arg(package_name); - emit status_message(msg); - emit status_update(msg); - QCoreApplication::processEvents(); - return true; - } else { - QString msg = tr("Not in repos: %1").arg(package_name); - emit status_message(msg); - emit status_update(msg); - QCoreApplication::processEvents(); - return false; +Downloader::Downloader(const QString &university, const QString &department, + std::shared_ptr manager, QObject *parent) + : QObject(parent), + name_of_university(university), + name_of_department(department), + package_manager(std::move(manager)) {} + +void Downloader::start() { + const QList entries = + Utils::loadPackageList(Utils::packageListPath(name_of_university, name_of_department)); + + // Package name -> software name, for the messages + QHash software_by_package; + for (const Utils::PackageEntry &entry : entries) { + const QString package = entry.packages.value(package_manager->name()); + if (package.isEmpty()) + report(tr("Not packaged for %1: %2").arg(package_manager->name(), entry.name)); + else + software_by_package.insert(package, entry.name); } -} -bool Downloader::is_in_apt_repo(const QString &package_name) { - QProcess process; - process.start("apt-cache", QStringList() << "show" << package_name); - process.waitForFinished(); - - QString stdout_output = process.readAllStandardOutput(); - - if (process.exitCode() == 0 && stdout_output.contains("Package:")) { - QString msg = tr("Found: %1").arg(package_name); - emit status_message(msg); - emit status_update(msg); - QCoreApplication::processEvents(); - return true; - } else { - QString msg = tr("Not in repos: %1").arg(package_name); - emit status_message(msg); - emit status_update(msg); - QCoreApplication::processEvents(); - return false; + if (software_by_package.isEmpty()) { + fail(tr("No %1 package list found for %2.").arg(package_manager->name(), name_of_department)); + return; } + check_packages(software_by_package); } -QStringList Downloader::read_package_list(bool standard_package_manager, QString package_manager) { - QStringList installable_with_standard_package_manager; - QStringList installable_with_non_standard_package_manager; +void Downloader::check_packages(const QHash &software_by_package) { + report(tr("Checking packages for %1...").arg(name_of_department)); - if (standard_package_manager && package_manager == "pacman") { - QString filepath_of_list = ":/lists/" + name_of_university + "/" + name_of_department + "/pacman_list.txt"; - QString msg = tr("Checking packages for %1...").arg(name_of_department); - emit status_message(msg); - emit status_update(msg); - QFile file(filepath_of_list); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - qDebug() << "Critical Error: Could not open the file!" << file.errorString(); - return installable_with_standard_package_manager; - } - QTextStream in(&file); - while (!in.atEnd()) { - QString package = in.readLine().trimmed(); - if (!package.isEmpty() && is_in_pacman_repo(package)) { - QString addMsg = tr("Adding: %1").arg(package); - emit status_message(addMsg); - emit status_update(addMsg); - installable_with_standard_package_manager.append(package); - } - } - file.close(); - return installable_with_standard_package_manager; - } + QStringList packages = software_by_package.keys(); + packages.sort(); - if (!standard_package_manager && package_manager == "pacman") { - QString filepath_of_list = ":/lists/" + name_of_university + "/" + name_of_department + "/pacman_list.txt"; - QString msg = tr("Checking packages for %1...").arg(name_of_department); - emit status_message(msg); - emit status_update(msg); - QFile file(filepath_of_list); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - qDebug() << "Critical Error: Could not open the file!" << file.errorString(); - return installable_with_non_standard_package_manager; - } - QTextStream in(&file); - while (!in.atEnd()) { - QString package = in.readLine().trimmed(); - if (!package.isEmpty() && !is_in_pacman_repo(package)) { - QString addMsg = tr("Adding (non-standard): %1").arg(package); - emit status_message(addMsg); - emit status_update(addMsg); - installable_with_non_standard_package_manager.append(package); - } - } - file.close(); - return installable_with_non_standard_package_manager; - } + QProcess *query = new QProcess(this); + current_process = query; - if (standard_package_manager && package_manager == "apt") { - QString filepath_of_list = ":/lists/" + name_of_university + "/" + name_of_department + "/apt_list.txt"; - QString msg = tr("Checking packages for %1...").arg(name_of_department); - emit status_message(msg); - emit status_update(msg); - QFile file(filepath_of_list); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - qDebug() << "Critical Error: Could not open the file!" << file.errorString(); - return installable_with_standard_package_manager; - } - QTextStream in(&file); - while (!in.atEnd()) { - QString package = in.readLine().trimmed(); - if (!package.isEmpty() && is_in_apt_repo(package)) { - QString addMsg = tr("Adding: %1").arg(package); - emit status_message(addMsg); - emit status_update(addMsg); - installable_with_standard_package_manager.append(package); - } + // parse_available() matches untranslated field names + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert("LC_ALL", "C"); + query->setProcessEnvironment(env); + + connect_start_failure(query); + connect(query, &QProcess::finished, this, [this, query, packages, software_by_package]() { + if (cancel_requested) { + query->deleteLater(); + fail(tr("The installation was cancelled.")); + return; } - file.close(); - return installable_with_standard_package_manager; - } - if (!standard_package_manager && package_manager == "apt") { - QString filepath_of_list = ":/lists/" + name_of_university + "/" + name_of_department + "/apt_list.txt"; - QString msg = tr("Checking packages for %1...").arg(name_of_department); -emit status_message(msg); -emit status_update(msg); - QFile file(filepath_of_list); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - qDebug() << "Critical Error: Could not open the file!" << file.errorString(); - return installable_with_non_standard_package_manager; - } - QTextStream in(&file); - while (!in.atEnd()) { - QString package = in.readLine().trimmed(); - if (!package.isEmpty() && !is_in_apt_repo(package)) { - QString addMsg = tr("Adding (non-standard): %1").arg(package); - emit status_message(addMsg); - emit status_update(addMsg);; - installable_with_non_standard_package_manager.append(package); + // The exit code is non-zero as soon as one package is missing, so only the output is used + const QSet available = + package_manager->parse_available(QString::fromUtf8(query->readAllStandardOutput())); + query->deleteLater(); + + QStringList installable; + for (const QString &package : packages) { + const QString label = QString("%1 (%2)").arg(software_by_package.value(package), package); + if (available.contains(package)) { + report(tr("Found: %1").arg(label)); + installable.append(package); + } else { + report(tr("Not in repos: %1").arg(label)); } } - file.close(); - return installable_with_non_standard_package_manager; - } + install(installable); + }); - qDebug() << "Critical Error: Unsupported package manager:" << package_manager; - return {}; + query->start(package_manager->query_program(), package_manager->query_arguments(packages)); } -void Downloader::download_via_pacman(const QStringList &list_to_be_downloaded) { - // there is a problem with pacman -Syu though, so the user should be alerted about that - QMessageBox::warning( - nullptr, - tr("Warning"), - tr("It is advised to update your system before proceeding.\nRun sudo pacman -Syu") - ); - - if (list_to_be_downloaded.isEmpty()) { - qDebug() << "Download list is empty\nNothing to do."; - emit download_completed(false); +void Downloader::install(const QStringList &packages) { + if (packages.isEmpty()) { + fail(tr("None of the packages are available in your repositories.")); return; } - qDebug() << "Starting to download package list via pacman"; + report(tr("Installing %n package(s)...", nullptr, static_cast(packages.size()))); + package_manager->begin_install(static_cast(packages.size())); + pending_output.clear(); - int total = list_to_be_downloaded.size(); - int *downloaded = new int(0); + QProcess *process = new QProcess(this); + current_process = process; + // Nobody can answer a prompt, so a read must hit EOF instead of waiting forever + process->setStandardInputFile(QProcess::nullDevice()); - QProcess *download_process = new QProcess(this); - - connect(download_process, &QProcess::readyReadStandardOutput, this, [=]() { - QString output = download_process->readAllStandardOutput(); - emit status_message(output); - - // Download phase: "downloading pkgname..." - static QRegularExpression download_re(R"(downloading (.+)\.\.\.)"); - QRegularExpressionMatchIterator it = download_re.globalMatch(output); - int download_count = 0; - while (it.hasNext()) { - it.next(); - download_count++; - } - if (download_count > 0 && total > 0) { - *downloaded += download_count; - int percent = static_cast((*downloaded * 50.0) / total); - emit progress_updated(qMin(percent, 49)); - } - - // Install phase: "(1/23) installing pkgname" - static QRegularExpression pacman_re(R"(\((\d+)/(\d+)\))"); - QRegularExpressionMatch match = pacman_re.match(output); - if (match.hasMatch()) { - int current = match.captured(1).toInt(); - int pkg_total = match.captured(2).toInt(); - if (pkg_total > 0) { - int percent = 50 + static_cast((current * 50.0) / pkg_total); - emit progress_updated(qMin(percent, 99)); - } - } + connect(process, &QProcess::readyReadStandardOutput, this, [this, process]() { + mark_package_manager_running(); + handle_install_output(process->readAllStandardOutput()); }); - - connect(download_process, &QProcess::readyReadStandardError, this, [=]() { - emit status_message(download_process->readAllStandardError()); + connect(process, &QProcess::readyReadStandardError, this, [this, process]() { + mark_package_manager_running(); + const QString errors = QString::fromUtf8(process->readAllStandardError()).trimmed(); + if (!errors.isEmpty()) + emit log_message(errors); }); - connect(download_process, QOverload::of(&QProcess::finished), - this, [=](int exitCode, QProcess::ExitStatus status) { - delete downloaded; - qDebug() << "Process finished with exit code:" << exitCode << "status:" << status; - if (exitCode == 0) { - qDebug() << "Package list downloaded via pacman"; - } else { - qDebug() << "Error downloading packages via pacman. Exit code:" << exitCode; - } - emit download_completed(exitCode == 0); - download_process->deleteLater(); + connect_start_failure(process); + connect(process, &QProcess::finished, this, [this, process](int exit_code, QProcess::ExitStatus status) { + qDebug() << "Process finished with exit code:" << exit_code << "status:" << status; + flush_install_output(); + process->deleteLater(); + + if (status != QProcess::NormalExit && cancel_requested) + fail(tr("The installation was cancelled.")); + else if (status != QProcess::NormalExit) + fail(tr("The package manager stopped unexpectedly.")); + else if (exit_code != 0) + fail(describe_exit(exit_code)); + else + emit finished(true, QString()); }); - QStringList command_structure; - command_structure << "pacman" << "-S" << "--noconfirm" << "--needed" << list_to_be_downloaded; - qDebug() << "Executing: pkexec" << command_structure.join(" "); - download_process->start("pkexec", command_structure); + // pkexec clears the environment, so variables have to go through env. + // LC_ALL=C keeps the output untranslated for parse_progress(). + const QStringList arguments = QStringList{"env", "LC_ALL=C"} + + package_manager->install_environment() + + package_manager->install_command(packages); + qDebug() << "Executing: pkexec" << arguments.join(' '); + process->start("pkexec", arguments); } -void Downloader::download_via_apt(const QStringList &list_to_be_downloaded) { - if (list_to_be_downloaded.isEmpty()) { - qDebug() << "Download list is empty\nNothing to do."; - emit download_completed(false); +void Downloader::cancel() { + if (!current_process || cancel_requested || package_manager_running) return; - } + cancel_requested = true; + report(tr("Cancelling...")); + // Before authorization pkexec still runs as the user, so this reaches it and polkit + // drops the pending request. Once pkexec has become root the signal is refused, + // the install carries on, and mark_package_manager_running() tells the user so. + current_process->kill(); +} - qDebug() << "Starting to download package list via apt"; +void Downloader::mark_package_manager_running() { + if (package_manager_running) + return; + package_manager_running = true; + emit cancellable_changed(false); + if (cancel_requested) + report(tr("The installation had already started and can no longer be cancelled.")); +} - QProcess *download_process = new QProcess(this); +void Downloader::handle_install_output(const QByteArray &data) { + // Chunks can end mid-line; keep the partial line until the rest arrives + pending_output += QString::fromUtf8(data); + const qsizetype end = pending_output.lastIndexOf('\n'); + if (end < 0) + return; - QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); - env.insert("DEBIAN_FRONTEND", "noninteractive"); - download_process->setProcessEnvironment(env); + const QString complete = pending_output.left(end); + pending_output.remove(0, end + 1); - connect(download_process, &QProcess::readyReadStandardOutput, this, [=]() { - emit status_message(download_process->readAllStandardOutput()); - }); - connect(download_process, &QProcess::readyReadStandardError, this, [=]() { - emit status_message(download_process->readAllStandardError()); - }); - connect(download_process, QOverload::of(&QProcess::finished), - this, [=](int exitCode, QProcess::ExitStatus status) { - qDebug() << "Process finished with exit code:" << exitCode << "status:" << status; - if (exitCode == 0) { - qDebug() << "Package list downloaded via apt"; - } else { - qDebug() << "Error downloading packages via apt. Exit code:" << exitCode; - } - emit download_completed(exitCode == 0); - download_process->deleteLater(); + emit log_message(complete); + for (const QString &line : complete.split('\n')) { + if (const std::optional percent = package_manager->parse_progress(line)) + emit progress_updated(*percent); + } +} + +void Downloader::flush_install_output() { + if (!pending_output.isEmpty()) + handle_install_output(QByteArrayLiteral("\n")); +} + +void Downloader::connect_start_failure(QProcess *process) { + connect(process, &QProcess::errorOccurred, this, [this, process](QProcess::ProcessError error) { + // finished() is not emitted when the process never started, e.g. pkexec is missing + if (error != QProcess::FailedToStart) + return; + const QString message = tr("Could not run %1: %2").arg(process->program(), process->errorString()); + process->deleteLater(); + fail(message); }); +} + +void Downloader::report(const QString &message) { + emit log_message(message); + emit status_changed(message); +} - QStringList command_structure; - command_structure << "apt" << "install" << "-y" - << "-o" << "APT::Status-Fd=1" - << list_to_be_downloaded; - qDebug() << "Executing: pkexec" << command_structure.join(" "); - download_process->start("pkexec", command_structure); +void Downloader::fail(const QString &message) { + qWarning() << message; + emit log_message(message); + emit finished(false, message); } diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index e5aab63..f87b346 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -3,205 +3,256 @@ #include "ui_MainWindow.h" #include "MainWindow.hpp" #include "Downloader.hpp" -#include "utils/applyTranslator.hpp" -#include "utils/loadUniversities.hpp" +#include "PackageManager.hpp" +#include "utils/language.hpp" +#include "utils/packageList.hpp" -#include -#include +#include #include -#include -#include #include -#include -#include -#include -#include -#include +#include +#include +#include +#include + +#include + +namespace { + const QString success_chunk_style = QStringLiteral("QProgressBar::chunk { background-color: #4CAF50; }"); + const QString failure_chunk_style = QStringLiteral("QProgressBar::chunk { background-color: #f44336; }"); +} MainWindow::MainWindow(QWidget *parent) - : QMainWindow(parent), ui(new Ui::MainWindow) { + : QMainWindow(parent), ui(std::make_unique()) { ui->setupUi(this); university_model = new QStandardItemModel(this); department_model = new QStandardItemModel(this); - Utils::loadUniversities(":/universities.json",departments_by_university ,university_model); + universities = Utils::loadUniversities(":/universities.json"); + for (const Utils::University &university : universities) { + QStandardItem *item = new QStandardItem(QIcon(university.icon), university.name); + item->setData(university.name, Qt::UserRole); + university_model->appendRow(item); + } ui->listView->setModel(university_model); - showing_universities = true; ui->progressBar->setVisible(false); ui->statusLabel->setVisible(false); ui->outputView->setVisible(false); ui->showMoreButton->setVisible(false); - connect(ui->listView, &QListView::clicked, this, &MainWindow::on_university_selection); + connect(ui->listView, &QListView::clicked, this, &MainWindow::handle_item_clicked); + connect(ui->backButton, &QPushButton::clicked, this, &MainWindow::show_universities); connect(ui->showMoreButton, &QPushButton::clicked, this, &MainWindow::toggle_output); + connect(ui->cancelButton, &QPushButton::clicked, this, [this]() { + if (downloader) + downloader->cancel(); + }); - // Language selector in status bar - QComboBox *langCombo = new QComboBox(this); - langCombo->addItem("English", "en_US"); - langCombo->addItem("Ελληνικά", "el_GR"); - - QSettings settings; - QString currentLang = settings.value("language", "en_US").toString(); - int idx = langCombo->findData(currentLang); - if (idx >= 0) { - langCombo->blockSignals(true); - langCombo->setCurrentIndex(idx); - langCombo->blockSignals(false); - } - - statusBar()->addPermanentWidget(langCombo); + package_manager = PackageManager::detect(); - connect(langCombo, &QComboBox::currentIndexChanged, this, [=](int index) { - QString lang = langCombo->itemData(index).toString(); - QSettings s; - s.setValue("language", lang); - Utils::applyTranslator(lang); - QEvent ev(QEvent::LanguageChange); - QCoreApplication::sendEvent(this, &ev); - }); + if (universities.isEmpty()) + set_status([this]() { return tr("Could not load the list of universities."); }); + else if (!package_manager) + set_status([this]() { return tr("No supported package manager (pacman or apt) was found."); }); + setup_language_selector(); retranslate(); } MainWindow::~MainWindow() { - delete ui; + // A running Downloader is deleted with this window's children, after ui is gone + if (downloader) + downloader->disconnect(this); } +void MainWindow::setup_language_selector() { + QComboBox *language_combo = new QComboBox(this); + for (const Utils::Language &language : Utils::supportedLanguages()) + language_combo->addItem(language.native_name, language.code); + + // Select the language main() applied, which falls back to the system locale + const QLocale::Language saved = QLocale(Utils::savedLanguage()).language(); + for (int i = 0; i < language_combo->count(); ++i) { + if (QLocale(language_combo->itemData(i).toString()).language() == saved) + language_combo->setCurrentIndex(i); + } + + statusBar()->addPermanentWidget(language_combo); + connect(language_combo, &QComboBox::currentIndexChanged, this, [this, language_combo](int index) { + const QString language = language_combo->itemData(index).toString(); + Utils::saveLanguage(language); + // Swapping the translator sends LanguageChange, which retranslates the .ui texts. + // University names come from the JSON, so refresh them even if no translator changed. + Utils::applyTranslator(language); + retranslate(); + }); +} void MainWindow::toggle_output() { output_visible = !output_visible; ui->outputView->setVisible(output_visible); - ui->showMoreButton->setText(output_visible ? "Hide details ▲" : "Show details ▼"); + retranslate(); } -void MainWindow::on_university_selection(const QModelIndex &index) { - if (showing_universities) { - current_university = university_model->data(index, Qt::UserRole).toString(); +void MainWindow::handle_item_clicked(const QModelIndex &index) { + const QString key = index.data(Qt::UserRole).toString(); - const QStringList departments = departments_by_university.value(current_university); + if (showing_universities) + show_departments(key); + else + start_install(key); +} - department_model->clear(); +void MainWindow::show_universities() { + ui->listView->setModel(university_model); + ui->backButton->setVisible(false); + showing_universities = true; +} - QStandardItem *backItem = new QStandardItem(tr("Back to Universities")); - backItem->setData("__back__", Qt::UserRole); - department_model->appendRow(backItem); +void MainWindow::show_departments(const QString &university_name) { + const Utils::University *university = find_university(university_name); + if (!university) + return; + + current_university = university->name; + department_model->clear(); + + // Departments without packages for this system's package manager cannot be installed + for (const Utils::CatalogEntry &department : university->departments) { + QStandardItem *item = new QStandardItem; + item->setData(department.name, Qt::UserRole); + item->setEnabled(package_manager + && Utils::hasPackagesFor( + Utils::loadPackageList(Utils::packageListPath(university->name, department.name)), + package_manager->name())); + department_model->appendRow(item); + } - for (const QString &dept : departments) { - QStandardItem *item = new QStandardItem(tr(dept.toUtf8().constData())); - item->setData(dept, Qt::UserRole); - department_model->appendRow(item); - } + ui->listView->setModel(department_model); + ui->backButton->setVisible(true); + showing_universities = false; + retranslate(); +} - ui->listView->setModel(department_model); - showing_universities = false; +void MainWindow::start_install(const QString &department) { + if (!package_manager || !confirm_install()) + return; + + install_state = InstallState::Running; + ui->listView->setEnabled(false); + ui->backButton->setEnabled(false); + ui->outputView->clear(); + ui->progressBar->setMaximum(100); + ui->progressBar->setValue(0); + ui->progressBar->setStyleSheet(""); + ui->progressBar->setVisible(true); + ui->showMoreButton->setVisible(true); + ui->cancelButton->setEnabled(true); + ui->cancelButton->setVisible(true); + set_status([this]() { return tr("Checking packages..."); }); + retranslate(); - } else { - QString selectedDept = department_model->data(index, Qt::UserRole).toString(); + downloader = new Downloader(current_university, department, package_manager, this); - if (selectedDept == "__back__") { - ui->listView->setModel(university_model); - showing_universities = true; - return; - } + connect(downloader, &Downloader::log_message, ui->outputView, &QPlainTextEdit::appendPlainText); + connect(downloader, &Downloader::status_changed, this, [this](const QString &message) { + // Already translated by the Downloader; stays in the language it was sent in + set_status([message]() { return message; }); + }); + connect(downloader, &Downloader::progress_updated, ui->progressBar, &QProgressBar::setValue); + connect(downloader, &Downloader::cancellable_changed, ui->cancelButton, &QPushButton::setEnabled); + connect(downloader, &Downloader::finished, this, &MainWindow::finish_install); - Downloader *downloader = new Downloader(current_university, selectedDept, this); - QString package_manager = downloader->check_package_manager(); - - if (package_manager != "Unsupported") { - ui->listView->setEnabled(false); - ui->outputView->clear(); - ui->progressBar->setMaximum(100); - ui->progressBar->setValue(0); - ui->progressBar->setFormat("%p%"); - ui->progressBar->setStyleSheet(""); - ui->progressBar->setVisible(true); - ui->statusLabel->setText("Checking packages..."); - ui->statusLabel->setVisible(true); - ui->showMoreButton->setVisible(true); - - connect(downloader, &Downloader::status_message, this, [=](const QString &msg) { - ui->outputView->append(msg); - for (const QString &line : msg.split('\n')) { - if (line.startsWith("dlstatus:") || line.startsWith("pmstatus:")) { - QStringList parts = line.split(':'); - if (parts.size() >= 3) { - bool ok; - double percent = parts[2].toDouble(&ok); - if (ok) { - ui->progressBar->setMaximum(100); - ui->progressBar->setValue(static_cast(percent)); - } - } - } - } - }); - - connect(downloader, &Downloader::status_update, this, [=](const QString &msg) { - ui->statusLabel->setText(msg.trimmed()); - }); - - connect(downloader, &Downloader::download_completed, this, [=](bool success) { - ui->listView->setEnabled(true); - ui->progressBar->setMaximum(100); - ui->progressBar->setValue(100); - if (success) { - ui->statusLabel->setText("✓ Finished!"); - ui->progressBar->setFormat("Done!"); - ui->progressBar->setStyleSheet("QProgressBar::chunk { background-color: #4CAF50; }"); - } else { - ui->statusLabel->setText("✗ Installation failed."); - ui->progressBar->setFormat("Failed"); - ui->progressBar->setStyleSheet("QProgressBar::chunk { background-color: #f44336; }"); - } - downloader->deleteLater(); - }); - - connect(downloader, &Downloader::progress_updated, this, [=](int percent) { - ui->progressBar->setMaximum(100); - ui->progressBar->setValue(percent); - }); - - QStringList packages_to_download = downloader->read_package_list(true, package_manager); - - if (package_manager == "pacman") { - downloader->download_via_pacman(packages_to_download); - } else if (package_manager == "apt") { - downloader->download_via_apt(packages_to_download); - } - } else { - ui->statusLabel->setText("Error: Unsupported Package Manager"); - ui->statusLabel->setVisible(true); - } - } + downloader->start(); +} + +bool MainWindow::confirm_install() { + const QString warning = package_manager->pre_install_warning(); + if (warning.isEmpty()) + return true; + + QMessageBox box(QMessageBox::Warning, tr("Warning"), warning, QMessageBox::NoButton, this); + QPushButton *continue_button = box.addButton(tr("Continue"), QMessageBox::AcceptRole); + box.addButton(tr("Cancel"), QMessageBox::RejectRole); + box.setDefaultButton(continue_button); + box.exec(); + return box.clickedButton() == continue_button; +} + +void MainWindow::finish_install(bool success, const QString &error) { + install_state = success ? InstallState::Succeeded : InstallState::Failed; + ui->listView->setEnabled(true); + ui->backButton->setEnabled(true); + ui->cancelButton->setVisible(false); + ui->progressBar->setValue(100); + ui->progressBar->setStyleSheet(success ? success_chunk_style : failure_chunk_style); + + if (success) + set_status([this]() { return tr("✓ Finished!"); }); + else + set_status([this, error]() { return tr("✗ Installation failed: %1").arg(error); }); + retranslate(); + + if (downloader) + downloader->deleteLater(); +} + +void MainWindow::set_status(std::function text) { + status_text = std::move(text); + ui->statusLabel->setText(status_text().trimmed()); + ui->statusLabel->setVisible(true); +} + +const Utils::University *MainWindow::find_university(const QString &name) const { + const auto it = std::find_if(universities.cbegin(), universities.cend(), + [&name](const Utils::University &university) { return university.name == name; }); + return it == universities.cend() ? nullptr : &*it; } void MainWindow::retranslate() { - for (int i = 0; i < university_model->rowCount(); ++i) { - QStandardItem *item = university_model->item(i); - QString key = item->data(Qt::UserRole).toString(); - QString translated = tr(key.toUtf8().constData()); - item->setText(translated.isEmpty() ? key : translated); + const QString language = Utils::savedLanguage(); + + for (int row = 0; row < university_model->rowCount(); ++row) { + QStandardItem *item = university_model->item(row); + if (const Utils::University *university = find_university(item->data(Qt::UserRole).toString())) + item->setText(university->display_name(language)); } - if (!showing_universities) { - for (int i = 0; i < department_model->rowCount(); ++i) { - QStandardItem *item = department_model->item(i); - QString key = item->data(Qt::UserRole).toString(); - if (key == "__back__") { - item->setText(tr("Back to Universities")); - } else { - QString translated = tr(key.toUtf8().constData()); - item->setText(translated.isEmpty() ? key : translated); + if (const Utils::University *university = find_university(current_university)) { + for (int row = 0; row < department_model->rowCount(); ++row) { + QStandardItem *item = department_model->item(row); + const QString key = item->data(Qt::UserRole).toString(); + for (const Utils::CatalogEntry &department : university->departments) { + if (department.name == key) + item->setText(department.display_name(language)); } + item->setToolTip(item->isEnabled() ? QString() + : tr("No package list is available for this department yet.")); } } + if (status_text) + ui->statusLabel->setText(status_text().trimmed()); + + switch (install_state) { + case InstallState::Succeeded: + ui->progressBar->setFormat(tr("Done!")); + break; + case InstallState::Failed: + ui->progressBar->setFormat(tr("Failed")); + break; + default: + ui->progressBar->setFormat("%p%"); + break; + } + ui->showMoreButton->setText(output_visible ? tr("Hide details ▲") : tr("Show details ▼")); + ui->cancelButton->setToolTip(tr("Once the package manager has started, stopping it could break your system.")); } void MainWindow::changeEvent(QEvent *event) { @@ -211,3 +262,15 @@ void MainWindow::changeEvent(QEvent *event) { } QMainWindow::changeEvent(event); } + +void MainWindow::closeEvent(QCloseEvent *event) { + // Interrupting apt or pacman can leave the package database broken. Before they + // start, closing is a cancel: the processes are killed along with the Downloader. + if (install_state == InstallState::Running && !ui->cancelButton->isEnabled()) { + QMessageBox::information(this, tr("Installation in progress"), + tr("Please wait until the installation finishes.")); + event->ignore(); + return; + } + QMainWindow::closeEvent(event); +} diff --git a/src/PackageManager.cpp b/src/PackageManager.cpp new file mode 100644 index 0000000..858ba52 --- /dev/null +++ b/src/PackageManager.cpp @@ -0,0 +1,167 @@ +// Authors: Apostolos Chalis 2026 , Ioannis Michadasis 2026 +#include "PackageManager.hpp" + +#include +#include +#include + +namespace { + QSet captured_names(const QRegularExpression &re, const QString &text) { + QSet names; + QRegularExpressionMatchIterator it = re.globalMatch(text); + while (it.hasNext()) + names.insert(it.next().captured(1)); + return names; + } + + /// Maps @p done of @p total onto [first, first + 49], so each phase gets half the bar. + int half_bar(int first, int done, int total) { + return first + qMin(static_cast((done * 50.0) / total), 49); + } +} + +std::unique_ptr PackageManager::detect() { + if (!QStandardPaths::findExecutable("pacman").isEmpty()) { + qDebug() << "Pacman found."; + return std::make_unique(); + } + if (!QStandardPaths::findExecutable("apt-get").isEmpty()) { + qDebug() << "Apt found."; + return std::make_unique(); + } + qDebug() << "No supported package manager found."; + return nullptr; +} + +QStringList PackageManager::install_environment() const { + return {}; +} + +void PackageManager::begin_install(int package_count) { + Q_UNUSED(package_count); +} + +QString PackageManager::pre_install_warning() const { + return {}; +} + +// ---- pacman ---- + +QString PacmanBackend::name() const { + return "pacman"; +} + +QString PacmanBackend::query_program() const { + return "pacman"; +} + +QStringList PacmanBackend::query_arguments(const QStringList &packages) const { + return QStringList{"-Si"} + packages; +} + +QSet PacmanBackend::parse_available(const QString &query_output) const { + // One "Name : pkgname" line per package found in the sync databases + static const QRegularExpression name_re(R"(^Name\s*:\s*(\S+))", QRegularExpression::MultilineOption); + return captured_names(name_re, query_output); +} + +QStringList PacmanBackend::install_command(const QStringList &packages) const { + return QStringList{"pacman", "-S", "--noconfirm", "--needed"} + packages; +} + +void PacmanBackend::begin_install(int package_count) { + // Replaced by the real total, dependencies included, once pacman prints it + total = package_count; + downloaded = 0; + installed = 0; +} + +std::optional PacmanBackend::parse_progress(const QString &line) { + // Without a terminal pacman prints plain lines instead of progress bars: + // "Packages (23) foo-1.0-1 bar-2.0-1 ..." + // " foo-1.0-1-x86_64 downloading..." (pacman >= 6; older: "downloading foo...") + // "installing foo..." (may be prefixed with "(3/23) ") + static const QRegularExpression total_re(R"(^Packages \((\d+)\))"); + static const QRegularExpression download_re(R"(\bdownloading\b)"); + static const QRegularExpression install_re(R"(^(?:\((\d+)/(\d+)\) )?(?:installing|upgrading|reinstalling) )"); + + if (const QRegularExpressionMatch match = total_re.match(line); match.hasMatch()) { + total = match.captured(1).toInt(); + return std::nullopt; + } + if (total <= 0) + return std::nullopt; + + if (download_re.match(line).hasMatch()) + return half_bar(0, ++downloaded, total); + + if (const QRegularExpressionMatch match = install_re.match(line); match.hasMatch()) { + if (!match.captured(1).isEmpty()) { + installed = match.captured(1).toInt(); + total = qMax(total, match.captured(2).toInt()); + } else { + ++installed; + } + return half_bar(50, installed, total); + } + return std::nullopt; +} + +QString PacmanBackend::pre_install_warning() const { + // pacman -S without a prior -Syu can leave the system partially upgraded + // Qualified so lupdate files it under the same context that tr() uses at runtime + return PackageManager::tr("It is advised to update your system before proceeding.\nRun sudo pacman -Syu"); +} + +// ---- apt ---- + +QString AptBackend::name() const { + return "apt"; +} + +QString AptBackend::query_program() const { + return "apt-cache"; +} + +QStringList AptBackend::query_arguments(const QStringList &packages) const { + return QStringList{"show", "--no-all-versions"} + packages; +} + +QSet AptBackend::parse_available(const QString &query_output) const { + // One "Package: pkgname" stanza per installable package; purely virtual ones print none + static const QRegularExpression package_re(R"(^Package:\s*(\S+))", QRegularExpression::MultilineOption); + return captured_names(package_re, query_output); +} + +QStringList AptBackend::install_command(const QStringList &packages) const { + // Stale package lists can 404 on download, so refresh them first (this upgrades nothing), + // in the same shell so the user authorizes once. Packages reach the script as "$@". + // apt-get rather than apt: apt's output is not meant for scripts and warns about it. + // Only the install gets Status-Fd, so the update does not move the progress bar. + // The Dpkg options keep a locally modified config file on upgrade instead of asking. + static const QString script = + "apt-get update && exec apt-get install -y -o APT::Status-Fd=1" + " -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold \"$@\""; + return QStringList{"sh", "-c", script, "sh"} + packages; +} + +QStringList AptBackend::install_environment() const { + return {"DEBIAN_FRONTEND=noninteractive"}; +} + +std::optional AptBackend::parse_progress(const QString &line) { + // "dlstatus:::" while downloading, "pmstatus:::" in dpkg + const bool downloading = line.startsWith("dlstatus:"); + if (!downloading && !line.startsWith("pmstatus:")) + return std::nullopt; + + const QStringList parts = line.split(':'); + if (parts.size() < 3) + return std::nullopt; + + bool ok; + const double percent = parts[2].toDouble(&ok); + if (!ok) + return std::nullopt; + return half_bar(downloading ? 0 : 50, static_cast(percent), 100); +} diff --git a/src/main.cpp b/src/main.cpp index a41ae36..ae6485a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,28 +1,22 @@ // Author: Apostolos Chalis 2026 #include "MainWindow.hpp" -#include "utils/applyTranslator.hpp" +#include "utils/language.hpp" #include -#include -#include -#include +#include -int main(int argc, char *argv[]){ - qputenv("QT_QPA_PLATFORMTHEME", ""); - QApplication uni_backpack_app(argc, argv); - uni_backpack_app.setOrganizationName("UniBackpack"); +int main(int argc, char *argv[]) { + qputenv("QT_QPA_PLATFORMTHEME", ""); + QApplication uni_backpack_app(argc, argv); + uni_backpack_app.setOrganizationName("UniBackpack"); uni_backpack_app.setApplicationName("UniBackpack"); - uni_backpack_app.setWindowIcon(QIcon(":/icons/unibackpack.png")); - uni_backpack_app.setDesktopFileName("UniBackpack"); + uni_backpack_app.setWindowIcon(QIcon(":/icons/unibackpack.png")); + uni_backpack_app.setDesktopFileName("unibackpack"); - // Load saved language, default to system locale, fall back to English - QSettings settings; - QString lang = settings.value("language", QLocale::system().name()).toString(); - Utils::applyTranslator(lang); + Utils::applyTranslator(Utils::savedLanguage()); + MainWindow main_window; + main_window.show(); - MainWindow main_window; - main_window.show(); - - return uni_backpack_app.exec(); + return uni_backpack_app.exec(); } diff --git a/src/utils/applyTranslator.cpp b/src/utils/applyTranslator.cpp deleted file mode 100644 index a6796b7..0000000 --- a/src/utils/applyTranslator.cpp +++ /dev/null @@ -1,24 +0,0 @@ -#include "utils/applyTranslator.hpp" -#include -#include "QCoreApplication" - -namespace Utils { - void applyTranslator(const QString &locale) { - static QTranslator elTranslator; - static bool installed = false; - - if (installed) { - QCoreApplication::removeTranslator(&elTranslator); - installed = false; - } - - if (locale.startsWith("el")) { - if (elTranslator.isEmpty()) // if no translation is found, the original English text is shown - elTranslator.load(":/i18n/unibackpack_el.qm"); - if (!elTranslator.isEmpty()) { - QCoreApplication::installTranslator(&elTranslator); - installed = true; - } - } - } -} diff --git a/src/utils/language.cpp b/src/utils/language.cpp new file mode 100644 index 0000000..7f3888b --- /dev/null +++ b/src/utils/language.cpp @@ -0,0 +1,54 @@ +#include "utils/language.hpp" + +#include +#include +#include +#include +#include + +namespace Utils { + namespace { + const QString language_key = QStringLiteral("language"); + + // Replaces whatever @p translator held with the best match for @p locale + void reinstall(QTranslator &translator, bool &installed, const QLocale &locale, + const QString &file_name, const QString &directory) { + if (installed) { + QCoreApplication::removeTranslator(&translator); + installed = false; + } + if (translator.load(locale, file_name, "_", directory) && !translator.isEmpty()) { + QCoreApplication::installTranslator(&translator); + installed = true; + } + } + } + + QList supportedLanguages() { + return { + {"en_US", "English"}, + {"el_GR", "Ελληνικά"}, + }; + } + + void applyTranslator(const QString &locale) { + static QTranslator app_translator; + static bool app_installed = false; + static QTranslator qt_translator; + static bool qt_installed = false; + + const QLocale qlocale(locale); + // Tries unibackpack_el_GR.qm, then unibackpack_el.qm, and so on + reinstall(app_translator, app_installed, qlocale, "unibackpack", ":/i18n"); + reinstall(qt_translator, qt_installed, qlocale, "qtbase", + QLibraryInfo::path(QLibraryInfo::TranslationsPath)); + } + + QString savedLanguage() { + return QSettings().value(language_key, QLocale::system().name()).toString(); + } + + void saveLanguage(const QString &locale) { + QSettings().setValue(language_key, locale); + } +} diff --git a/src/utils/loadUniversities.cpp b/src/utils/loadUniversities.cpp index 6749184..5c4c74c 100644 --- a/src/utils/loadUniversities.cpp +++ b/src/utils/loadUniversities.cpp @@ -1,74 +1,61 @@ -/** - * @file loadUniversities.hpp - * @brief Loads the university/department catalogue from a JSON resource. - */ - #include "utils/loadUniversities.hpp" -#include -#include +#include #include #include -#include +#include #include -#include +#include +#include -/// @brief Free helper functions shared across the UI. namespace Utils { - /** - * @brief Parses the universities JSON and fills the lookup table and list model. - * - * The file must contain a JSON array of objects of the form - * @code{.json} - * [ - * { - * "name": "Aristotle University of Thessaloniki", - * "icon": ":/icons/auth_logo.png", - * "departments": ["Informatics"] - * } - * ] - * @endcode - * Entries with an empty or missing @c name are skipped. - * For each university, one QStandardItem is appended to @p university_model. - * Its display text and Qt::UserRole data are both set to the name, and its icon comes from @c icon. - * - * @param path Path to the JSON file, usually a Qt resource such as ":/universities.json". - * @param departments_by_university Output map from university name to its department names. - * Existing entries with the same key are overwritten. - * @param university_model Model to append university rows to. Must not be null. - * Existing rows are not cleared. - * - * @note If the file cannot be opened or is not a valid JSON array, a warning is logged - * and neither output is modified. - */ - void loadUniversities(const QString &path, QHash &departments_by_university, QStandardItemModel *university_model) { + namespace { + CatalogEntry read_entry(const QJsonObject &object) { + CatalogEntry entry; + entry.name = object["name"].toString(); + const QJsonObject translations = object["translations"].toObject(); + for (auto it = translations.begin(); it != translations.end(); ++it) + entry.translations.insert(it.key(), it.value().toString()); + return entry; + } + } + + QString CatalogEntry::display_name(const QString &locale) const { + const QString language = QLocale(locale).name().section('_', 0, 0); + return translations.value(language, name); + } + + QList loadUniversities(const QString &path) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) { qWarning() << "Could not open" << path; - return; + return {}; } QJsonParseError error; - QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); if (error.error != QJsonParseError::NoError || !doc.isArray()) { qWarning() << "Invalid universities JSON:" << error.errorString(); - return; + return {}; } + QList universities; for (const QJsonValue &value : doc.array()) { - QJsonObject uni = value.toObject(); - QString key = uni["name"].toString(); - if (key.isEmpty()) - continue; + const QJsonObject object = value.toObject(); - QStringList departments; - for (const QJsonValue &dept : uni["departments"].toArray()) - departments << dept.toString(); - departments_by_university.insert(key, departments); + University university; + static_cast(university) = read_entry(object); + if (university.name.isEmpty()) + continue; + university.icon = object["icon"].toString(); - QStandardItem *item = new QStandardItem(QIcon(uni["icon"].toString()), key); - item->setData(key, Qt::UserRole); - university_model->appendRow(item); + for (const QJsonValue &department : object["departments"].toArray()) { + CatalogEntry entry = read_entry(department.toObject()); + if (!entry.name.isEmpty()) + university.departments.append(entry); + } + universities.append(university); } + return universities; } } diff --git a/src/utils/packageList.cpp b/src/utils/packageList.cpp new file mode 100644 index 0000000..e5341c4 --- /dev/null +++ b/src/utils/packageList.cpp @@ -0,0 +1,54 @@ +#include "utils/packageList.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace Utils { + QString packageListPath(const QString &university, const QString &department) { + return ":/lists/" + university + "/" + department + "/packages.json"; + } + + QList loadPackageList(const QString &path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + qWarning() << "Could not open package list" << path << file.errorString(); + return {}; + } + + QJsonParseError error; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError || !doc.isArray()) { + qWarning() << "Invalid package list" << path << error.errorString(); + return {}; + } + + QList entries; + for (const QJsonValue &value : doc.array()) { + const QJsonObject object = value.toObject(); + + PackageEntry entry; + entry.name = object["name"].toString(); + if (entry.name.isEmpty()) + continue; + for (auto it = object.begin(); it != object.end(); ++it) { + const QString package = it.value().toString().trimmed(); + if (it.key() != "name" && !package.isEmpty()) + entry.packages.insert(it.key(), package); + } + entries.append(entry); + } + return entries; + } + + bool hasPackagesFor(const QList &entries, const QString &package_manager) { + return std::any_of(entries.begin(), entries.end(), [&package_manager](const PackageEntry &entry) { + return entry.packages.contains(package_manager); + }); + } +} diff --git a/tests/tst_package_manager.cpp b/tests/tst_package_manager.cpp new file mode 100644 index 0000000..08e7ee7 --- /dev/null +++ b/tests/tst_package_manager.cpp @@ -0,0 +1,190 @@ +#include "PackageManager.hpp" +#include "utils/loadUniversities.hpp" +#include "utils/packageList.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace { + QStringList sorted(const QSet &set) { + QStringList list(set.begin(), set.end()); + list.sort(); + return list; + } + + // -1 for "no progress", so results print readably when a comparison fails + int progress(PackageManager &manager, const QString &line) { + return manager.parse_progress(line).value_or(-1); + } + + QString write_temporary(QTemporaryFile &file, const QByteArray &contents) { + if (!file.open()) + return {}; + file.write(contents); + file.close(); + return file.fileName(); + } +} + +class TestPackageManager : public QObject { + Q_OBJECT + +private slots: + void pacmanParsesAvailablePackages(); + void aptParsesAvailablePackages(); + void pacmanReportsProgress(); + void aptReportsProgress(); + void aptUpdatesBeforeInstalling(); + void displayNameFallsBackToEnglish(); + void loadsPackageList(); + void bundledDataIsConsistent(); +}; + +void TestPackageManager::pacmanParsesAvailablePackages() { + // pacman -Si bash nodejs not-a-package, run with LC_ALL=C (the error goes to stderr) + const QString output = + "Repository : core\n" + "Name : bash\n" + "Version : 5.2.037-1\n" + "Description : The GNU Bourne Again shell\n" + "\n" + "Repository : extra\n" + "Name : nodejs\n" + "Version : 23.5.0-1\n"; + + PacmanBackend pacman; + QCOMPARE(sorted(pacman.parse_available(output)), (QStringList{"bash", "nodejs"})); +} + +void TestPackageManager::aptParsesAvailablePackages() { + // apt-cache show --no-all-versions bash gcc, run with LC_ALL=C + const QString output = + "Package: bash\n" + "Version: 5.2.21-2ubuntu4\n" + "Pre-Depends: libc6 (>= 2.38)\n" + "Description: GNU Bourne Again SHell\n" + "\n" + "Package: gcc\n" + "Version: 4:13.2.0-7ubuntu1\n" + "Package-Type: deb\n"; + + AptBackend apt; + QCOMPARE(sorted(apt.parse_available(output)), (QStringList{"bash", "gcc"})); +} + +void TestPackageManager::pacmanReportsProgress() { + PacmanBackend pacman; + pacman.begin_install(2); + + // The real total, with dependencies, replaces the requested count + QCOMPARE(progress(pacman, "Packages (4) a-1-1 b-1-1 c-1-1 d-1-1"), -1); + + // Downloads fill 0-49 + QCOMPARE(progress(pacman, " a-1-1-x86_64 downloading..."), 12); + QCOMPARE(progress(pacman, " b-1-1-x86_64 downloading..."), 25); + + // Installs fill 50-99, counted or taken from "(n/total)" + QCOMPARE(progress(pacman, "installing a..."), 62); + QCOMPARE(progress(pacman, "(4/4) installing d..."), 99); + QCOMPARE(progress(pacman, "(1/4) checking keys in keyring"), -1); + QCOMPARE(progress(pacman, ":: Processing package changes..."), -1); +} + +void TestPackageManager::aptReportsProgress() { + AptBackend apt; + apt.begin_install(3); + + // Downloads fill 0-49 and dpkg 50-99, so the bar never moves backwards + QCOMPARE(progress(apt, "dlstatus:1:20.0:Retrieving file 1 of 5"), 10); + QCOMPARE(progress(apt, "pmstatus:bash:50.0:Installing bash"), 75); + QCOMPARE(progress(apt, "pmstatus:bash:100:Installed bash"), 99); + QCOMPARE(progress(apt, "Reading package lists..."), -1); + QCOMPARE(progress(apt, "pmstatus:broken"), -1); +} + +void TestPackageManager::aptUpdatesBeforeInstalling() { + AptBackend apt; + const QStringList command = apt.install_command({"gcc", "$(reboot)"}); + + // One shell runs update, then install; package names stay separate arguments ("$@") + QCOMPARE(command.size(), 6); + QCOMPARE(command.mid(0, 2), (QStringList{"sh", "-c"})); + QVERIFY(command[2].startsWith("apt-get update && exec apt-get install ")); + QVERIFY(command[2].contains("--force-confold")); + QVERIFY(!command[2].contains("gcc")); + QCOMPARE(command.mid(3), (QStringList{"sh", "gcc", "$(reboot)"})); +} + +void TestPackageManager::displayNameFallsBackToEnglish() { + Utils::CatalogEntry entry; + entry.name = "Informatics"; + entry.translations.insert("el", "Τμήμα Πληροφορικής"); + + QCOMPARE(entry.display_name("el_GR"), QString("Τμήμα Πληροφορικής")); + QCOMPARE(entry.display_name("en_US"), QString("Informatics")); + QCOMPARE(entry.display_name("de_DE"), QString("Informatics")); +} + +void TestPackageManager::loadsPackageList() { + QTemporaryFile file; + const QString path = write_temporary(file, R"([ + { "name": "Java JDK", "apt": "default-jdk", "pacman": "jdk-openjdk" }, + { "name": "FreeCAD", "pacman": "freecad", "apt": " " }, + { "apt": "nameless" } + ])"); + QVERIFY(!path.isEmpty()); + + const QList entries = Utils::loadPackageList(path); + QCOMPARE(entries.size(), 2); + QCOMPARE(entries[0].name, QString("Java JDK")); + QCOMPARE(entries[0].packages.value("apt"), QString("default-jdk")); + QCOMPARE(entries[0].packages.value("pacman"), QString("jdk-openjdk")); + QVERIFY(!entries[1].packages.contains("apt")); + + QVERIFY(Utils::hasPackagesFor(entries, "pacman")); + QVERIFY(!Utils::hasPackagesFor(entries, "dnf")); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^Could not open package list")); + QVERIFY(Utils::loadPackageList("/does/not/exist.json").isEmpty()); +} + +void TestPackageManager::bundledDataIsConsistent() { + const QDir resources(QStringLiteral(UNIBACKPACK_SOURCE_DIR "/resources")); + + const QList universities = + Utils::loadUniversities(resources.filePath("universities.json")); + QVERIFY2(!universities.isEmpty(), "universities.json is missing or invalid"); + + QSet catalogued_lists; + for (const Utils::University &university : universities) { + QVERIFY2(QFile::exists(resources.filePath(university.icon.mid(2))), + qPrintable("Missing icon " + university.icon)); + + for (const Utils::CatalogEntry &department : university.departments) { + const QString path = resources.filePath("lists/" + university.name + "/" + department.name + "/packages.json"); + catalogued_lists.insert(QDir::cleanPath(path)); + if (!QFile::exists(path)) + continue; // Shown greyed out in the app + + const QList entries = Utils::loadPackageList(path); + QVERIFY2(!entries.isEmpty(), qPrintable(path + " is empty or invalid")); + for (const Utils::PackageEntry &entry : entries) + QVERIFY2(!entry.packages.isEmpty(), qPrintable(entry.name + " in " + path + " has no packages")); + } + } + + // A list whose directory names don't match the catalogue is never shown + QDirIterator it(resources.filePath("lists"), {"packages.json"}, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString path = QDir::cleanPath(it.next()); + QVERIFY2(catalogued_lists.contains(path), + qPrintable(path + " does not match a university and department in universities.json")); + } +} + +QTEST_GUILESS_MAIN(TestPackageManager) +#include "tst_package_manager.moc" diff --git a/translations/unibackpack_el.ts b/translations/unibackpack_el.ts index 4e36d0c..2b87488 100644 --- a/translations/unibackpack_el.ts +++ b/translations/unibackpack_el.ts @@ -4,48 +4,83 @@ Downloader - - + + No %1 package list found for %2. + Δεν βρέθηκε λίστα πακέτων %1 για %2. + + + + Checking packages for %1... + Έλεγχος πακέτων για %1... + + + + + The installation was cancelled. + Η εγκατάσταση ακυρώθηκε. + + + Found: %1 Βρέθηκε: %1 - - + Not in repos: %1 Δεν βρέθηκε στα αποθετήρια: %1 - - - - - Checking packages for %1... - Έλεγχος πακέτων για %1... + + None of the packages are available in your repositories. + Κανένα από τα πακέτα δεν είναι διαθέσιμο στα αποθετήριά σας. + + + + Installing %n package(s)... + + Εγκατάσταση %n πακέτου... + Εγκατάσταση %n πακέτων... + - - - Adding: %1 - Προσθήκη: %1 + + The package manager stopped unexpectedly. + Ο διαχειριστής πακέτων τερματίστηκε απροσδόκητα. - - - Adding (non-standard): %1 - Προσθήκη (μη τυπικό): %1 + + Authorization was cancelled. + Η εξουσιοδότηση ακυρώθηκε. - - Warning - Προειδοποίηση + + You are not authorized to install packages. + Δεν έχετε δικαίωμα εγκατάστασης πακέτων. - - It is advised to update your system before proceeding. -Run sudo pacman -Syu - Συνιστάται να ενημερώσετε το σύστημά σας πριν συνεχίσετε. -Εκτελέστε sudo pacman -Syu + + The package manager failed with exit code %1. + Ο διαχειριστής πακέτων απέτυχε με κωδικό εξόδου %1. + + + + Cancelling... + Ακύρωση... + + + + The installation had already started and can no longer be cancelled. + Η εγκατάσταση είχε ήδη ξεκινήσει και δεν μπορεί πλέον να ακυρωθεί. + + + + Could not run %1: %2 + Αδυναμία εκτέλεσης του %1: %2 + + + + Not packaged for %1: %2 + Δεν υπάρχει πακέτο %1 για: %2 @@ -56,52 +91,101 @@ Run sudo pacman -Syu UniBackpack - - + + Show details ▼ Εμφάνιση λεπτομερειών ▼ - + + Hide details ▲ + Απόκρυψη λεπτομερειών ▲ + + + Back to Universities Επιστροφή στην επιλογή Πανεπιστημίων - - Hide details ▲ - Απόκρυψη λεπτομερειών ▲ + + No supported package manager (pacman or apt) was found. + Δεν βρέθηκε υποστηριζόμενος διαχειριστής πακέτων (pacman ή apt). + + + + No package list is available for this department yet. + Δεν υπάρχει ακόμη λίστα πακέτων για αυτό το τμήμα. + + + + Warning + Προειδοποίηση + + + + Checking packages... + Έλεγχος πακέτων... + + + + ✓ Finished! + ✓ Ολοκληρώθηκε! - Aristotle University of Thessaloniki - Αριστοτέλειο Πανεπιστήμιο Θεσσαλονίκης + + Done! + Έτοιμο! - University of Western Macedonia - Πανεπιστήμιο Δυτικής Μακεδονίας + + Once the package manager has started, stopping it could break your system. + Αφού ξεκινήσει ο διαχειριστής πακέτων, η διακοπή του μπορεί να χαλάσει το σύστημά σας. - University of Macedonia - Πανεπιστήμιο Μακεδονίας + + ✗ Installation failed: %1 + ✗ Η εγκατάσταση απέτυχε: %1 - Informatics - Τμήμα Πληροφορικής + + Failed + Αποτυχία - Physics - Τμήμα Φυσικής + + Could not load the list of universities. + Δεν ήταν δυνατή η φόρτωση της λίστας πανεπιστημίων. - Mechanical Engineering - Τμήμα Μηχανολογίας + + Continue + Συνέχεια - Applied Informatics - Τμήμα Εφαρμοσμένης Πληροφορικής + + + Cancel + Ακύρωση - Economics - Τμήμα Οικονομικών + + Installation in progress + Εγκατάσταση σε εξέλιξη + + + + Please wait until the installation finishes. + Περιμένετε να ολοκληρωθεί η εγκατάσταση. + + + + PackageManager + + + It is advised to update your system before proceeding. +Run sudo pacman -Syu + Συνιστάται να ενημερώσετε το σύστημά σας πριν συνεχίσετε. +Εκτελέστε sudo pacman -Syu - \ No newline at end of file + diff --git a/translations/unibackpack_en.ts b/translations/unibackpack_en.ts index 9211ae1..bfa8758 100644 --- a/translations/unibackpack_en.ts +++ b/translations/unibackpack_en.ts @@ -4,46 +4,82 @@ Downloader - - + + No %1 package list found for %2. + + + + + Checking packages for %1... + + + + + + The installation was cancelled. + + + + Found: %1 - - + Not in repos: %1 - - - - - Checking packages for %1... + + None of the packages are available in your repositories. + + + Installing %n package(s)... + + Installing %n package... + Installing %n packages... + + - - - Adding: %1 + + The package manager stopped unexpectedly. - - - Adding (non-standard): %1 + + Authorization was cancelled. - - Warning + + You are not authorized to install packages. - - It is advised to update your system before proceeding. -Run sudo pacman -Syu + + The package manager failed with exit code %1. + + + + + Cancelling... + + + + + The installation had already started and can no longer be cancelled. + + + + + Could not run %1: %2 + + + + + Not packaged for %1: %2 @@ -55,20 +91,99 @@ Run sudo pacman -Syu - - + + Show details ▼ - - + + Hide details ▲ + + + + Back to Universities - - Hide details ▲ + + No supported package manager (pacman or apt) was found. + + + + + No package list is available for this department yet. + + + + + Warning + + + + + Checking packages... + + + + + ✓ Finished! + + + + + Done! + + + + + Once the package manager has started, stopping it could break your system. + + + + + ✗ Installation failed: %1 + + + + + Failed + + + + + Could not load the list of universities. + + + + + Continue + + + + + + Cancel + + + + + Installation in progress + + + + + Please wait until the installation finishes. + + + + + PackageManager + + + It is advised to update your system before proceeding. +Run sudo pacman -Syu diff --git a/ui/MainWindow.ui b/ui/MainWindow.ui index d8189fc..a0ce2a0 100644 --- a/ui/MainWindow.ui +++ b/ui/MainWindow.ui @@ -15,6 +15,16 @@ + + + + false + + + Back to Universities + + + @@ -41,6 +51,16 @@ + + + + false + + + Cancel + + + @@ -52,7 +72,7 @@ - + false @@ -66,16 +86,6 @@ - - - - 0 - 0 - 800 - 29 - - -