commit 3038fcf94c996d2d17d6282f8ebd9ec5e5f339c1 Author: Tronax Date: Sat Jun 13 10:55:39 2026 +0200 ISO Downloader with native GUI and checksum verification diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bc0b67 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Build-Verzeichnisse +/build/ +/build-*/ +cmake-build-*/ + +# CMake-Artefakte +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +CTestTestfile.cmake +Makefile +*.cmake +!CMakeLists.txt + +# Qt / MOC / UIC / RCC +moc_*.cpp +moc_*.h +*_autogen/ +qrc_*.cpp +ui_*.h +*.qmake.stash +.qmake.cache + +# Kompilate +*.o +*.obj +*.so +*.a +*.dll +*.dylib +*.exe +glorious-iso-dl + +# Heruntergeladene ISOs und unfertige Downloads +*.iso +*.img +*.part + +# Editor / IDE +.idea/ +.vscode/ +*.user +*.swp +*~ +.DS_Store +compile_commands.json +.claude/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..5d75d41 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.21) +project(glorious-iso-dl VERSION 1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 REQUIRED COMPONENTS Widgets Network) + +qt_add_executable(glorious-iso-dl + src/main.cpp + src/Distro.h + src/DistroRegistry.cpp + src/Resolver.h + src/Resolver.cpp + src/DownloadManager.h + src/DownloadManager.cpp + src/MainWindow.h + src/MainWindow.cpp +) + +target_link_libraries(glorious-iso-dl PRIVATE Qt6::Widgets Qt6::Network) diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cb5160 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Glorious ISO Downloader + +Ein grafischer ISO-Downloader für Linux-Distributionen auf Basis von **C++ und Qt6**. +Das Programm ermittelt automatisch den aktuellen Download-Link der gewählten +Distribution und **verifiziert die heruntergeladene Datei anhand ihrer Prüfsumme**. + +## Funktionen + +- **Automatische Link-Auflösung** — keine manuelle Suche nach Mirrors oder + Versionsnummern. Das Programm liest Release-Seiten und Verzeichnislisten aus und + ermittelt selbstständig die neueste Version. +- **Prüfsummen-Verifikation während des Downloads** — der Hash wird im Stream + berechnet (MD5, SHA-1, SHA-256, SHA-512). Stimmt er nicht mit der offiziellen + Prüfsumme überein, wird die Datei **automatisch verworfen**. +- **50 unterstützte Distributionen**, gruppiert nach Familie (siehe unten). +- **Sicherheitshinweise** in der Oberfläche: + - Warnung bei unverschlüsselten HTTP-Quellen (verhindert keine, aber meldet + Manipulationsrisiken; HTTPS→HTTP-Downgrades bei Redirects werden blockiert). + - Warnung, wenn ein Projekt nur schwache Hashes (MD5/SHA-1) anbietet. + - Warnung, wenn überhaupt keine Prüfsumme verfügbar ist. +- **Versions-Fallback** — ist die scheinbar neueste Version noch nicht vollständig + veröffentlicht, probiert das Programm automatisch die nächstniedrigere. + +## Unterstützte Distributionen + +| Familie | Distributionen | +|---|---| +| **Arch** | Arch Linux, Manjaro KDE, CachyOS, EndeavourOS, Artix, Garuda Dr460nized, ArcoLinux¹, BlackArch | +| **Debian** | Debian, Kali, ParrotOS, Tails, Devuan, antiX, MX Linux | +| **Ubuntu** | Ubuntu, Kubuntu, Xubuntu, Lubuntu, Ubuntu MATE, Mint, Pop!_OS, KDE Neon, Linux Lite, Trisquel | +| **Red Hat / Fedora** | Fedora, Rocky, AlmaLinux, CentOS Stream, Qubes OS | +| **SUSE / Slackware / Mandriva** | openSUSE Tumbleweed, Slackware, Mageia, PCLinuxOS | +| **Unabhängig & Spezial** | Void, NixOS, Gentoo, Alpine, Deepin, Clear Linux¹, Puppy, TinyCore, GoboLinux¹, KaOS, Calculate Linux, Grml, SystemRescue, Proxmox VE, TrueNAS SCALE, Proxmox Backup Server | + +¹ *ArcoLinux* und *Clear Linux* wurden 2025 eingestellt, *GoboLinux* ist seit 2020 +inaktiv. Die Einträge sind enthalten, aber entsprechend markiert — die automatische +Auflösung kann fehlschlagen, da Mirrors abgeschaltet wurden. + +## Sicherheitshinweis + +Eine Prüfsummen-Verifikation schützt vor **defekten oder veränderten Downloads**, aber +nicht vor einer Kompromittierung der Bezugsquelle selbst. Für besonders +sicherheitskritische Images (z. B. Tails, Qubes OS, Trisquel) solltest du zusätzlich +die **GPG-Signatur** der Prüfsummendatei mit dem offiziellen Schlüssel des Projekts +prüfen. Das Programm weist in diesen Fällen darauf hin. + +## Bauen + +Voraussetzungen: **Qt 6** (Widgets, Network), **CMake ≥ 3.21**, ein C++20-Compiler. + +Unter Arch Linux: + +```bash +sudo pacman -S qt6-base cmake gcc +``` + +Bauen: + +```bash +cmake -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build -j$(nproc) +``` + +## Benutzung + +```bash +./build/glorious-iso-dl +``` + +1. Distribution im Baum links auswählen. +2. **„Aktuellen Download-Link ermitteln"** klicken — Version, URL und erwartete + Prüfsumme werden angezeigt. +3. Zielordner wählen und **„Herunterladen & verifizieren"** klicken. +4. Nach Abschluss meldet das Programm, ob die Prüfsumme stimmt. + +## Projektstruktur + +| Datei | Inhalt | +|---|---| +| `src/Distro.h` | Datenmodell für die deklarativen Auflösungs-Rezepte | +| `src/DistroRegistry.cpp` | Definitionen aller unterstützten Distributionen | +| `src/Resolver.{h,cpp}` | Dreistufige Link-Auflösung (Version → Seite → Prüfsumme) | +| `src/DownloadManager.{h,cpp}` | Download mit Streaming-Hashprüfung | +| `src/MainWindow.{h,cpp}` | Grafische Oberfläche | +| `src/main.cpp` | Einstiegspunkt | + +## Wartung + +Ändert ein Projekt sein URL-Schema oder seine Verzeichnisstruktur, muss der +betreffende Eintrag in `src/DistroRegistry.cpp` angepasst werden. Jeder Eintrag +ist deklarativ aufgebaut; das Format ist in `src/Distro.h` dokumentiert. diff --git a/src/Distro.h b/src/Distro.h new file mode 100644 index 0000000..0f71fd0 --- /dev/null +++ b/src/Distro.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +// Beschreibt, wie der aktuelle ISO-Link einer Distribution aufgelöst wird. +// +// Auflösung in bis zu 3 Schritten (alle optional, Platzhalter werden ersetzt): +// 1. versionUrl + versionRegex : Seite laden, alle Treffer (Capture-Gruppe 1) +// sammeln, höchste Version gewinnt -> %VERSION% +// 2. pageUrl + linkRegex : Seite laden, ISO-Link/Dateiname per Regex +// finden (höchster Treffer gewinnt), relativ zur pageUrl aufgelöst. +// hashRegex (optional) : Hash direkt aus derselben Seite ziehen +// checksumLinkRegex (opt.) : Link zur Prüfsummendatei aus der Seite ziehen +// 3. checksumUrl : Prüfsummendatei laden. +// Mit isoPattern + downloadBase: Dateiname wird AUS der Prüfsummendatei +// ermittelt (Listenmodus). Ohne isoPattern: Hash zum bereits bekannten +// Dateinamen suchen (oder einzelner Hash in der Datei). +// +// Platzhalter: %VERSION%, %ISOURL%, %FILENAME% +struct Distro { + QString id; + QString name; + QString family; + QString notes; + + QString versionUrl; + QString versionRegex; + + QString pageUrl; + QString linkRegex; + QString checksumLinkRegex; + QString hashRegex; + + QString isoUrl; // direktes URL-Template (falls kein linkRegex/isoPattern) + QString checksumUrl; + + QString isoPattern; // Listenmodus: Dateiname-Regex in der Prüfsummendatei + QString downloadBase; // Listenmodus: Basis-URL für den Download + + QCryptographicHash::Algorithm algo = QCryptographicHash::Sha256; +}; + +namespace DistroRegistry { +const QList& all(); +} diff --git a/src/DistroRegistry.cpp b/src/DistroRegistry.cpp new file mode 100644 index 0000000..b218d1b --- /dev/null +++ b/src/DistroRegistry.cpp @@ -0,0 +1,383 @@ +#include "Distro.h" + +// Hinweis: Die Auflösung basiert auf offiziellen Mirrors/Release-Seiten. +// Ändert ein Projekt sein URL-Schema, muss der Eintrag angepasst werden. + +const QList& DistroRegistry::all() +{ + static const QList list = { + + // ───────────────────────── Arch-Familie ───────────────────────── + Distro{ + .id = "arch", .name = "Arch Linux", .family = "Arch-Familie", + .checksumUrl = "https://geo.mirror.pkgbuild.com/iso/latest/sha256sums.txt", + .isoPattern = "archlinux-\\d{4}\\.\\d{2}\\.\\d{2}-x86_64\\.iso", + .downloadBase = "https://geo.mirror.pkgbuild.com/iso/latest/", + }, + Distro{ + .id = "manjaro-kde", .name = "Manjaro KDE", .family = "Arch-Familie", + .pageUrl = "https://manjaro.org/products/download/x86", + .linkRegex = "(https://download\\.manjaro\\.org/kde/[^\"']+?\\.iso)", + .checksumUrl = "%ISOURL%.sha512", + .algo = QCryptographicHash::Sha512, + }, + Distro{ + .id = "cachyos", .name = "CachyOS (Desktop)", .family = "Arch-Familie", + .versionUrl = "https://mirror.cachyos.org/ISO/desktop/", + .versionRegex = "href=\"(\\d{6,8})/\"", + .isoUrl = "https://mirror.cachyos.org/ISO/desktop/%VERSION%/cachyos-desktop-linux-%VERSION%.iso", + .checksumUrl = "%ISOURL%.sha256", + }, + Distro{ + .id = "endeavouros", .name = "EndeavourOS", .family = "Arch-Familie", + .pageUrl = "https://mirror.alpix.eu/endeavouros/iso/", + .linkRegex = "href=\"(EndeavourOS_[A-Za-z-]+-[\\d.\\-]+\\.iso)\"", + .checksumUrl = "%ISOURL%.sha512", + .algo = QCryptographicHash::Sha512, + }, + Distro{ + .id = "artix", .name = "Artix (Plasma/OpenRC)", .family = "Arch-Familie", + .checksumUrl = "https://download.artixlinux.org/iso/sha256sums", + .isoPattern = "artix-plasma-openrc-[\\w.\\-]+-x86_64\\.iso", + .downloadBase = "https://download.artixlinux.org/iso/", + }, + Distro{ + .id = "garuda", .name = "Garuda Dr460nized", .family = "Arch-Familie", + .isoUrl = "https://iso.builds.garudalinux.org/iso/latest/garuda/dr460nized/latest.iso", + .checksumUrl = "%ISOURL%.sha256", + }, + Distro{ + .id = "arcolinux", .name = "ArcoLinux", .family = "Arch-Familie", + .notes = "Projekt wurde 2025 eingestellt — bekannte Mirrors sind bereits offline, " + "die automatische Auflösung schlägt daher vermutlich fehl.", + .versionUrl = "https://mirror.accum.se/mirror/arcolinux/iso/", + .versionRegex = "href=\"(v[\\d.]+)/\"", + .isoUrl = "https://mirror.accum.se/mirror/arcolinux/iso/%VERSION%/arcolinuxl-%VERSION%-x86_64.iso", + .checksumUrl = "%ISOURL%.sha256", + }, + Distro{ + .id = "blackarch", .name = "BlackArch (Slim)", .family = "Arch-Familie", + .notes = "Projekt stellt nur SHA-1 bereit — zusätzlich GPG-Signatur prüfen!", + .pageUrl = "https://blackarch.org/downloads.html", + .linkRegex = "(https://[^\"]+/blackarch-linux-slim-[\\d.]+-x86_64\\.iso)", + .hashRegex = "blackarch-linux-slim-[\\d.]+-x86_64\\.iso[\\s\\S]{0,500}?([a-f0-9]{40})", + .algo = QCryptographicHash::Sha1, + }, + + // ───────────────────────── Debian-Familie ─────────────────────── + Distro{ + .id = "debian", .name = "Debian (Netinst)", .family = "Debian-Familie", + .checksumUrl = "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/SHA256SUMS", + .isoPattern = "debian-[\\d.]+-amd64-netinst\\.iso", + .downloadBase = "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/", + }, + Distro{ + .id = "kali", .name = "Kali Linux (Installer)", .family = "Debian-Familie", + .checksumUrl = "https://cdimage.kali.org/current/SHA256SUMS", + .isoPattern = "kali-linux-[\\d.]+[a-z]?-installer-amd64\\.iso", + .downloadBase = "https://cdimage.kali.org/current/", + }, + Distro{ + .id = "parrot", .name = "ParrotOS Security", .family = "Debian-Familie", + .versionUrl = "https://deb.parrot.sh/parrot/iso/", + .versionRegex = "href=\"(\\d+\\.\\d+(?:\\.\\d+)?)/\"", + .checksumUrl = "https://deb.parrot.sh/parrot/iso/%VERSION%/signed-hashes.txt", + .isoPattern = "Parrot-security-%VERSION%_amd64\\.iso", + .downloadBase = "https://deb.parrot.sh/parrot/iso/%VERSION%/", + }, + Distro{ + .id = "tails", .name = "Tails", .family = "Debian-Familie", + .pageUrl = "https://tails.net/install/v2/Tails/amd64/stable/latest.json", + .linkRegex = "\"url\":\\s*\"([^\"]+\\.iso)\"", + .hashRegex = "\"sha256\":\\s*\"([0-9a-f]{64})\"[\\s\\S]{0,300}?\\.iso\"", + }, + Distro{ + .id = "devuan", .name = "Devuan (Netinstall)", .family = "Debian-Familie", + .versionUrl = "https://files.devuan.org/", + .versionRegex = "announce/([a-z]+)-release-announce", + .checksumUrl = "https://files.devuan.org/devuan_%VERSION%/installer-iso/SHA256SUMS.txt", + .isoPattern = "devuan_[a-z]+_[\\d.]+_amd64_netinstall\\.iso", + .downloadBase = "https://files.devuan.org/devuan_%VERSION%/installer-iso/", + }, + Distro{ + .id = "antix", .name = "antiX (Full)", .family = "Debian-Familie", + .versionUrl = "https://mirrors.dotsrc.org/mx-isos/ANTIX/Final/", + .versionRegex = "href=\"antiX-([\\d.]+)/\"", + .pageUrl = "https://mirrors.dotsrc.org/mx-isos/ANTIX/Final/antiX-%VERSION%/", + .linkRegex = "href=\"(antiX-[\\d.]+_x64-full\\.iso)\"", + .checksumUrl = "%ISOURL%.sha256", + }, + Distro{ + .id = "mxlinux", .name = "MX Linux (Xfce)", .family = "Debian-Familie", + .pageUrl = "https://mirrors.dotsrc.org/mx-isos/MX/Final/Xfce/", + .linkRegex = "href=\"(MX-[\\d.]+_Xfce_x64\\.iso)\"", + .checksumUrl = "%ISOURL%.sha256", + }, + + // ───────────────────────── Ubuntu-Familie ─────────────────────── + Distro{ + .id = "ubuntu", .name = "Ubuntu Desktop", .family = "Ubuntu-Familie", + .versionUrl = "https://releases.ubuntu.com/", + .versionRegex = "href=\"(\\d\\d\\.\\d\\d(?:\\.\\d+)?)/\"", + .checksumUrl = "https://releases.ubuntu.com/%VERSION%/SHA256SUMS", + .isoPattern = "ubuntu-[\\d.]+-desktop-amd64\\.iso", + .downloadBase = "https://releases.ubuntu.com/%VERSION%/", + }, + Distro{ + .id = "kubuntu", .name = "Kubuntu", .family = "Ubuntu-Familie", + .versionUrl = "https://cdimage.ubuntu.com/kubuntu/releases/", + .versionRegex = "href=\"(\\d\\d\\.\\d\\d(?:\\.\\d+)?)/\"", + .checksumUrl = "https://cdimage.ubuntu.com/kubuntu/releases/%VERSION%/release/SHA256SUMS", + .isoPattern = "kubuntu-[\\d.]+-desktop-amd64\\.iso", + .downloadBase = "https://cdimage.ubuntu.com/kubuntu/releases/%VERSION%/release/", + }, + Distro{ + .id = "xubuntu", .name = "Xubuntu", .family = "Ubuntu-Familie", + .versionUrl = "https://cdimage.ubuntu.com/xubuntu/releases/", + .versionRegex = "href=\"(\\d\\d\\.\\d\\d(?:\\.\\d+)?)/\"", + .checksumUrl = "https://cdimage.ubuntu.com/xubuntu/releases/%VERSION%/release/SHA256SUMS", + .isoPattern = "xubuntu-[\\d.]+-desktop-amd64\\.iso", + .downloadBase = "https://cdimage.ubuntu.com/xubuntu/releases/%VERSION%/release/", + }, + Distro{ + .id = "lubuntu", .name = "Lubuntu", .family = "Ubuntu-Familie", + .versionUrl = "https://cdimage.ubuntu.com/lubuntu/releases/", + .versionRegex = "href=\"(\\d\\d\\.\\d\\d(?:\\.\\d+)?)/\"", + .checksumUrl = "https://cdimage.ubuntu.com/lubuntu/releases/%VERSION%/release/SHA256SUMS", + .isoPattern = "lubuntu-[\\d.]+-desktop-amd64\\.iso", + .downloadBase = "https://cdimage.ubuntu.com/lubuntu/releases/%VERSION%/release/", + }, + Distro{ + .id = "ubuntu-mate", .name = "Ubuntu MATE", .family = "Ubuntu-Familie", + .versionUrl = "https://cdimage.ubuntu.com/ubuntu-mate/releases/", + .versionRegex = "href=\"(\\d\\d\\.\\d\\d(?:\\.\\d+)?)/\"", + .checksumUrl = "https://cdimage.ubuntu.com/ubuntu-mate/releases/%VERSION%/release/SHA256SUMS", + .isoPattern = "ubuntu-mate-[\\d.]+-desktop-amd64\\.iso", + .downloadBase = "https://cdimage.ubuntu.com/ubuntu-mate/releases/%VERSION%/release/", + }, + Distro{ + .id = "mint", .name = "Linux Mint (Cinnamon)", .family = "Ubuntu-Familie", + .versionUrl = "https://mirrors.edge.kernel.org/linuxmint/stable/", + .versionRegex = "href=\"(\\d+(?:\\.\\d+)?)/\"", + .checksumUrl = "https://mirrors.edge.kernel.org/linuxmint/stable/%VERSION%/sha256sum.txt", + .isoPattern = "linuxmint-[\\d.]+-cinnamon-64bit\\.iso", + .downloadBase = "https://mirrors.edge.kernel.org/linuxmint/stable/%VERSION%/", + }, + Distro{ + .id = "popos", .name = "Pop!_OS", .family = "Ubuntu-Familie", + .notes = "Basis-Release (24.04) ist in der API-URL kodiert — bei neuer LTS anpassen.", + .pageUrl = "https://api.pop-os.org/builds/24.04/intel", + .linkRegex = "\"url\":\\s*\"([^\"]+\\.iso)\"", + .hashRegex = "\"sha_sum\":\\s*\"([0-9a-f]{64})\"", + }, + Distro{ + .id = "kde-neon", .name = "KDE Neon (User)", .family = "Ubuntu-Familie", + .pageUrl = "https://files.kde.org/neon/images/user/current/", + .linkRegex = "href=\"(neon-user-\\d{8}-\\d{4}\\.iso)\"", + .checksumLinkRegex = "href=\"(neon-user-\\d{8}-\\d{4}\\.sha256sum)\"", + }, + Distro{ + .id = "linux-lite", .name = "Linux Lite", .family = "Ubuntu-Familie", + .versionUrl = "https://repo.linuxliteos.com/linuxlite/isos/", + .versionRegex = "href=\"(\\d+\\.\\d+)/\"", + .isoUrl = "https://repo.linuxliteos.com/linuxlite/isos/%VERSION%/linux-lite-%VERSION%-64bit.iso", + .checksumUrl = "%ISOURL%.sha256", + }, + Distro{ + .id = "trisquel", .name = "Trisquel", .family = "Ubuntu-Familie", + .pageUrl = "https://mirror.fsf.org/trisquel-images/", + .linkRegex = "href=\"(trisquel_[\\d.]+_amd64\\.iso)\"", + .checksumUrl = "%ISOURL%.sha256", + }, + + // ─────────────────────── Red Hat / Fedora ─────────────────────── + Distro{ + .id = "fedora", .name = "Fedora Workstation", .family = "Red Hat / Fedora", + .versionUrl = "https://dl.fedoraproject.org/pub/fedora/linux/releases/", + .versionRegex = "href=\"(\\d+)/\"", + .pageUrl = "https://dl.fedoraproject.org/pub/fedora/linux/releases/%VERSION%/Workstation/x86_64/iso/", + .linkRegex = "href=\"(Fedora-Workstation-Live[\\w.\\-]*x86_64[\\w.\\-]*\\.iso)\"", + .checksumLinkRegex = "href=\"(Fedora-Workstation-[\\w.\\-]*-CHECKSUM)\"", + }, + Distro{ + .id = "rocky", .name = "Rocky Linux (Minimal)", .family = "Red Hat / Fedora", + .versionUrl = "https://download.rockylinux.org/pub/rocky/", + .versionRegex = "href=\"(\\d+\\.\\d+)/\"", + .checksumUrl = "https://download.rockylinux.org/pub/rocky/%VERSION%/isos/x86_64/CHECKSUM", + .isoPattern = "Rocky-[\\d.]+-x86_64-minimal\\.iso", + .downloadBase = "https://download.rockylinux.org/pub/rocky/%VERSION%/isos/x86_64/", + }, + Distro{ + .id = "almalinux", .name = "AlmaLinux (Minimal)", .family = "Red Hat / Fedora", + .versionUrl = "https://repo.almalinux.org/almalinux/", + .versionRegex = "href=\"(\\d+\\.\\d+)/\"", + .checksumUrl = "https://repo.almalinux.org/almalinux/%VERSION%/isos/x86_64/CHECKSUM", + .isoPattern = "AlmaLinux-[\\d.]+-x86_64-minimal\\.iso", + .downloadBase = "https://repo.almalinux.org/almalinux/%VERSION%/isos/x86_64/", + }, + Distro{ + .id = "centos-stream", .name = "CentOS Stream (Boot)", .family = "Red Hat / Fedora", + .versionUrl = "https://mirror.stream.centos.org/", + .versionRegex = "href=\"(\\d+)-stream/\"", + .checksumUrl = "https://mirror.stream.centos.org/%VERSION%-stream/BaseOS/x86_64/iso/CentOS-Stream-%VERSION%-latest-x86_64-boot.iso.SHA256SUM", + .isoPattern = "CentOS-Stream-\\d+-latest-x86_64-boot\\.iso", + .downloadBase = "https://mirror.stream.centos.org/%VERSION%-stream/BaseOS/x86_64/iso/", + }, + Distro{ + .id = "qubes", .name = "Qubes OS", .family = "Red Hat / Fedora", + .notes = "Zusätzlich GPG-Signatur der DIGESTS-Datei prüfen (qubes-os.org/security).", + .pageUrl = "https://mirrors.edge.kernel.org/qubes/iso/", + .linkRegex = "href=\"(Qubes-R[\\d.]+-x86_64\\.iso)\"", + .checksumUrl = "%ISOURL%.DIGESTS", + .algo = QCryptographicHash::Sha512, + }, + + // ─────────────── SUSE / Slackware / Mandriva ──────────────────── + Distro{ + .id = "tumbleweed", .name = "openSUSE Tumbleweed (DVD)", .family = "SUSE / Slackware / Mandriva", + .isoUrl = "https://download.opensuse.org/tumbleweed/iso/openSUSE-Tumbleweed-DVD-x86_64-Current.iso", + .checksumUrl = "%ISOURL%.sha256", + }, + Distro{ + .id = "slackware", .name = "Slackware64 (DVD)", .family = "SUSE / Slackware / Mandriva", + .notes = "Projekt bietet nur MD5 an — MD5 ist kryptografisch gebrochen, GPG-Signatur prüfen!", + .versionUrl = "https://mirrors.slackware.com/slackware/slackware-iso/", + .versionRegex = "href=\"slackware64-([\\d.]+)-iso/\"", + .isoUrl = "https://mirrors.slackware.com/slackware/slackware-iso/slackware64-%VERSION%-iso/slackware64-%VERSION%-install-dvd.iso", + .checksumUrl = "%ISOURL%.md5", + .algo = QCryptographicHash::Md5, + }, + Distro{ + .id = "mageia", .name = "Mageia (Live Plasma)", .family = "SUSE / Slackware / Mandriva", + .versionUrl = "https://mirrors.kernel.org/mageia/iso/", + .versionRegex = "href=\"(\\d+)/\"", + .isoUrl = "https://mirrors.kernel.org/mageia/iso/%VERSION%/Mageia-%VERSION%-Live-Plasma-x86_64/Mageia-%VERSION%-Live-Plasma-x86_64.iso", + .checksumUrl = "%ISOURL%.sha512", + .algo = QCryptographicHash::Sha512, + }, + Distro{ + .id = "pclinuxos", .name = "PCLinuxOS (KDE)", .family = "SUSE / Slackware / Mandriva", + .notes = "Projekt bietet nur MD5 an — MD5 ist kryptografisch gebrochen.", + .pageUrl = "https://ftp.nluug.nl/os/Linux/distr/pclinuxos/iso/", + .linkRegex = "href=\"(pclinuxos64-kde-[\\d.]+\\.iso)\"", + .checksumLinkRegex = "href=\"(pclinuxos64-kde-[\\d.]+\\.md5sum)\"", + .algo = QCryptographicHash::Md5, + }, + + // ─────────────────── Unabhängig & Spezial ─────────────────────── + Distro{ + .id = "void", .name = "Void Linux (Xfce)", .family = "Unabhängig & Spezial", + .checksumUrl = "https://repo-default.voidlinux.org/live/current/sha256sum.txt", + .isoPattern = "void-live-x86_64-\\d+-xfce\\.iso", + .downloadBase = "https://repo-default.voidlinux.org/live/current/", + }, + Distro{ + .id = "nixos", .name = "NixOS (Graphical)", .family = "Unabhängig & Spezial", + .pageUrl = "https://nixos.org/download/", + .linkRegex = "(https://channels\\.nixos\\.org/nixos-[\\d.]+/latest-nixos-graphical-x86_64-linux\\.iso)", + .checksumUrl = "%ISOURL%.sha256", + }, + Distro{ + .id = "gentoo", .name = "Gentoo (Minimal)", .family = "Unabhängig & Spezial", + .pageUrl = "https://distfiles.gentoo.org/releases/amd64/autobuilds/current-install-amd64-minimal/", + .linkRegex = "href=\"(install-amd64-minimal-\\d{8}T\\d{6}Z\\.iso)\"", + .checksumUrl = "%ISOURL%.sha256", + }, + Distro{ + .id = "alpine", .name = "Alpine (Standard)", .family = "Unabhängig & Spezial", + .pageUrl = "https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/x86_64/", + .linkRegex = "href=\"(alpine-standard-[\\d.]+-x86_64\\.iso)\"", + .checksumUrl = "%ISOURL%.sha256", + }, + Distro{ + .id = "deepin", .name = "Deepin", .family = "Unabhängig & Spezial", + .versionUrl = "https://mirrors.kernel.org/deepin-cd/", + .versionRegex = "href=\"(\\d[\\d.]*)/\"", + .checksumUrl = "https://mirrors.kernel.org/deepin-cd/%VERSION%/amd64/SHA256SUMS", + .isoPattern = "deepin-desktop-community-[\\w.\\-]*amd64\\.iso", + .downloadBase = "https://mirrors.kernel.org/deepin-cd/%VERSION%/amd64/", + }, + Distro{ + .id = "clearlinux", .name = "Clear Linux (Live Desktop)", .family = "Unabhängig & Spezial", + .notes = "Intel hat Clear Linux 2025 eingestellt — Download-Server sind teilweise " + "bereits abgeschaltet, Auflösung kann fehlschlagen.", + .versionUrl = "https://cdn.download.clearlinux.org/latest", + .versionRegex = "(\\d+)", + .isoUrl = "https://cdn.download.clearlinux.org/releases/%VERSION%/clear/clear-%VERSION%-live-desktop.iso", + .checksumUrl = "%ISOURL%-SHA512SUMS", + .algo = QCryptographicHash::Sha512, + }, + Distro{ + .id = "puppy", .name = "Puppy (BookwormPup64)", .family = "Unabhängig & Spezial", + .versionUrl = "https://distro.ibiblio.org/puppylinux/puppy-bookwormpup/BookwormPup64/", + .versionRegex = "href=\"([\\d.]+)/\"", + .pageUrl = "https://distro.ibiblio.org/puppylinux/puppy-bookwormpup/BookwormPup64/%VERSION%/", + .linkRegex = "href=\"(BookwormPup64_[\\d.]+\\.iso)\"", + .checksumUrl = "%ISOURL%-checksum.txt", + }, + Distro{ + .id = "tinycore", .name = "Tiny Core (CorePure64)", .family = "Unabhängig & Spezial", + .notes = "Projekt bietet nur HTTP + MD5 an — Download ungesichert, MD5 gebrochen!", + .versionUrl = "http://tinycorelinux.net/downloads.html", + .versionRegex = "(\\d+)\\.x", + .pageUrl = "http://tinycorelinux.net/%VERSION%.x/x86_64/release/", + .linkRegex = "href=\"(CorePure64-[\\d.]+\\.iso)\"", + .checksumUrl = "%ISOURL%.md5.txt", + .algo = QCryptographicHash::Md5, + }, + Distro{ + .id = "gobolinux", .name = "GoboLinux 017", .family = "Unabhängig & Spezial", + .notes = "Projekt inaktiv (letztes Release 2020), keine Prüfsumme automatisch verfügbar — " + "Link ggf. manuell auf gobolinux.org prüfen.", + .isoUrl = "https://gobolinux.org/release/017/GoboLinux-017-x86_64.iso", + }, + Distro{ + .id = "kaos", .name = "KaOS", .family = "Unabhängig & Spezial", + .pageUrl = "https://mirror.math.princeton.edu/pub/kaoslinux/", + .linkRegex = "href=\"(KaOS-\\d{4}\\.\\d{2}-x86_64\\.iso)\"", + .checksumLinkRegex = "href=\"(KaOS-\\d{4}\\.\\d{2}-x86_64\\.sha256sum)\"", + }, + Distro{ + .id = "calculate", .name = "Calculate Linux (Desktop KDE)", .family = "Unabhängig & Spezial", + .versionUrl = "https://mirror.calculate-linux.org/release/", + .versionRegex = "href=\"(\\d{8})/\"", + .checksumUrl = "https://mirror.calculate-linux.org/release/%VERSION%/SHA256SUMS", + .isoPattern = "cld-\\d{8}-x86_64\\.iso", + .downloadBase = "https://mirror.calculate-linux.org/release/%VERSION%/", + }, + Distro{ + .id = "grml", .name = "Grml (Full)", .family = "Unabhängig & Spezial", + .pageUrl = "https://mirror.de.leaseweb.net/grml/", + .linkRegex = "href=\"(grml-full-\\d{4}\\.\\d{2}-amd64\\.iso)\"", + .checksumUrl = "%ISOURL%.sha256", + }, + Distro{ + .id = "systemrescue", .name = "SystemRescue", .family = "Unabhängig & Spezial", + .versionUrl = "https://www.system-rescue.org/Download/", + .versionRegex = "releases/([\\d.]+)/systemrescue-", + .isoUrl = "https://fastly-cdn.system-rescue.org/releases/%VERSION%/systemrescue-%VERSION%-amd64.iso", + .checksumUrl = "%ISOURL%.sha256", + }, + Distro{ + .id = "proxmox-ve", .name = "Proxmox VE", .family = "Unabhängig & Spezial", + .checksumUrl = "https://enterprise.proxmox.com/iso/SHA256SUMS", + .isoPattern = "proxmox-ve_[\\d.]+-\\d+\\.iso", + .downloadBase = "https://enterprise.proxmox.com/iso/", + }, + Distro{ + .id = "proxmox-bs", .name = "Proxmox Backup Server", .family = "Unabhängig & Spezial", + .checksumUrl = "https://enterprise.proxmox.com/iso/SHA256SUMS", + .isoPattern = "proxmox-backup-server_[\\d.]+-\\d+\\.iso", + .downloadBase = "https://enterprise.proxmox.com/iso/", + }, + Distro{ + .id = "truenas-scale", .name = "TrueNAS SCALE", .family = "Unabhängig & Spezial", + .notes = "Release-Train (Fangtooth) ist in der URL kodiert — bei neuem Train anpassen.", + .versionUrl = "https://download.truenas.com/TrueNAS-SCALE-Fangtooth/", + .versionRegex = "href=\"\\.?/?([\\d.]+)/", + .isoUrl = "https://download.truenas.com/TrueNAS-SCALE-Fangtooth/%VERSION%/TrueNAS-SCALE-%VERSION%.iso", + .checksumUrl = "%ISOURL%.sha256", + }, + }; + return list; +} diff --git a/src/DownloadManager.cpp b/src/DownloadManager.cpp new file mode 100644 index 0000000..994d56a --- /dev/null +++ b/src/DownloadManager.cpp @@ -0,0 +1,104 @@ +#include "DownloadManager.h" +#include "Resolver.h" + +#include +#include +#include + +DownloadManager::DownloadManager(QNetworkAccessManager* nam, QObject* parent) + : QObject(parent), m_nam(nam) {} + +void DownloadManager::start(const QString& url, const QString& targetPath, + QCryptographicHash::Algorithm algo, + const QString& expectedHash) +{ + if (m_reply) + return; + + m_target = targetPath; + m_algo = algo; + m_expected = expectedHash.toLower(); + m_cancelled = false; + m_hasher = std::make_unique(algo); + + m_file.setFileName(targetPath + QStringLiteral(".part")); + if (!m_file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + emit finished(false, false, false, + tr("Datei kann nicht angelegt werden: %1").arg(m_file.errorString())); + return; + } + + QNetworkRequest req{QUrl(url)}; + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + req.setHeader(QNetworkRequest::UserAgentHeader, + QStringLiteral("glorious-iso-dl/1.0")); + + m_reply = m_nam->get(req); + connect(m_reply, &QNetworkReply::readyRead, this, [this]() { + const QByteArray chunk = m_reply->readAll(); + m_hasher->addData(chunk); + if (m_file.write(chunk) != chunk.size()) { + m_cancelled = true; + m_reply->abort(); + } + }); + connect(m_reply, &QNetworkReply::downloadProgress, + this, &DownloadManager::progress); + connect(m_reply, &QNetworkReply::finished, + this, &DownloadManager::onReplyFinished); +} + +void DownloadManager::cancel() +{ + if (m_reply) { + m_cancelled = true; + m_reply->abort(); + } +} + +void DownloadManager::onReplyFinished() +{ + QNetworkReply* reply = m_reply; + m_reply = nullptr; + reply->deleteLater(); + m_file.close(); + + if (m_cancelled) { + m_file.remove(); + emit finished(false, false, false, tr("Download abgebrochen.")); + return; + } + if (reply->error() != QNetworkReply::NoError) { + m_file.remove(); + emit finished(false, false, false, + tr("Download fehlgeschlagen: %1").arg(reply->errorString())); + return; + } + + const QString actual = QString::fromLatin1(m_hasher->result().toHex()); + const QString algoStr = Resolver::algoName(m_algo); + + if (m_expected.isEmpty()) { + QFile::remove(m_target); + m_file.rename(m_target); + emit finished(true, false, false, + tr("Download abgeschlossen — ACHTUNG: keine Prüfsumme verfügbar!\n" + "%1 der Datei: %2\nBitte manuell verifizieren.") + .arg(algoStr, actual)); + return; + } + if (actual == m_expected) { + QFile::remove(m_target); + m_file.rename(m_target); + emit finished(true, true, true, + tr("Download abgeschlossen, %1-Prüfsumme OK:\n%2") + .arg(algoStr, actual)); + } else { + m_file.remove(); // manipulierte/defekte Datei nicht behalten + emit finished(false, true, false, + tr("PRÜFSUMMENFEHLER! Datei wurde verworfen.\n" + "Erwartet (%1): %2\nErhalten: %3") + .arg(algoStr, m_expected, actual)); + } +} diff --git a/src/DownloadManager.h b/src/DownloadManager.h new file mode 100644 index 0000000..3ff605f --- /dev/null +++ b/src/DownloadManager.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; + +// Lädt eine Datei herunter und berechnet währenddessen den Hash (Streaming). +// Bei Hash-Mismatch wird die Datei verworfen. +class DownloadManager : public QObject { + Q_OBJECT +public: + explicit DownloadManager(QNetworkAccessManager* nam, QObject* parent = nullptr); + + void start(const QString& url, const QString& targetPath, + QCryptographicHash::Algorithm algo, const QString& expectedHash); + void cancel(); + bool isActive() const { return m_reply != nullptr; } + +signals: + void progress(qint64 received, qint64 total); + void finished(bool ok, bool hashChecked, bool hashOk, const QString& message); + +private: + void onReplyFinished(); + + QNetworkAccessManager* m_nam; + QNetworkReply* m_reply = nullptr; + QFile m_file; + std::unique_ptr m_hasher; + QCryptographicHash::Algorithm m_algo = QCryptographicHash::Sha256; + QString m_expected; + QString m_target; + bool m_cancelled = false; +}; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp new file mode 100644 index 0000000..bae6b0a --- /dev/null +++ b/src/MainWindow.cpp @@ -0,0 +1,304 @@ +#include "MainWindow.h" +#include "DownloadManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +MainWindow::MainWindow(QWidget* parent) + : QMainWindow(parent) +{ + m_nam = new QNetworkAccessManager(this); + m_resolver = new Resolver(m_nam, this); + m_downloader = new DownloadManager(m_nam, this); + + connect(m_resolver, &Resolver::progress, this, &MainWindow::log); + connect(m_resolver, &Resolver::resolved, this, &MainWindow::onResolved); + connect(m_resolver, &Resolver::failed, this, &MainWindow::onResolveFailed); + connect(m_downloader, &DownloadManager::progress, this, &MainWindow::onDownloadProgress); + connect(m_downloader, &DownloadManager::finished, this, &MainWindow::onDownloadFinished); + + buildUi(); + setWindowTitle(tr("Glorious ISO Downloader")); + resize(1100, 700); +} + +void MainWindow::buildUi() +{ + auto* splitter = new QSplitter(this); + + // Linke Seite: Distro-Baum, gruppiert nach Familie + m_tree = new QTreeWidget; + m_tree->setHeaderLabel(tr("Distribution")); + QString lastFamily; + QTreeWidgetItem* familyItem = nullptr; + for (const Distro& d : DistroRegistry::all()) { + if (d.family != lastFamily) { + familyItem = new QTreeWidgetItem(m_tree, {d.family}); + familyItem->setFlags(Qt::ItemIsEnabled); + QFont f = familyItem->font(0); + f.setBold(true); + familyItem->setFont(0, f); + familyItem->setExpanded(true); + lastFamily = d.family; + } + auto* item = new QTreeWidgetItem(familyItem, {d.name}); + item->setData(0, Qt::UserRole, d.id); + } + connect(m_tree, &QTreeWidget::currentItemChanged, this, &MainWindow::onSelectionChanged); + splitter->addWidget(m_tree); + + // Rechte Seite + auto* right = new QWidget; + auto* layout = new QVBoxLayout(right); + + m_titleLabel = new QLabel(tr("Bitte eine Distribution auswählen.")); + QFont tf = m_titleLabel->font(); + tf.setPointSizeF(tf.pointSizeF() * 1.4); + tf.setBold(true); + m_titleLabel->setFont(tf); + layout->addWidget(m_titleLabel); + + m_notesLabel = new QLabel; + m_notesLabel->setWordWrap(true); + m_notesLabel->setStyleSheet(QStringLiteral("color: #c08000;")); + m_notesLabel->hide(); + layout->addWidget(m_notesLabel); + + m_resolveBtn = new QPushButton(tr("Aktuellen Download-Link ermitteln")); + m_resolveBtn->setEnabled(false); + connect(m_resolveBtn, &QPushButton::clicked, this, &MainWindow::onResolveClicked); + layout->addWidget(m_resolveBtn); + + auto makeInfoLabel = []() { + auto* l = new QLabel(QStringLiteral("—")); + l->setTextInteractionFlags(Qt::TextSelectableByMouse); + l->setWordWrap(true); + return l; + }; + auto* infoLayout = new QVBoxLayout; + infoLayout->addWidget(new QLabel(tr("Version:"))); + infoLayout->addWidget(m_versionLabel = makeInfoLabel()); + infoLayout->addWidget(new QLabel(tr("URL:"))); + infoLayout->addWidget(m_urlLabel = makeInfoLabel()); + infoLayout->addWidget(new QLabel(tr("Erwartete Prüfsumme:"))); + infoLayout->addWidget(m_hashLabel = makeInfoLabel()); + layout->addLayout(infoLayout); + + auto* dirLayout = new QHBoxLayout; + dirLayout->addWidget(new QLabel(tr("Zielordner:"))); + m_dirEdit = new QLineEdit( + QStandardPaths::writableLocation(QStandardPaths::DownloadLocation)); + dirLayout->addWidget(m_dirEdit, 1); + auto* browseBtn = new QPushButton(tr("…")); + connect(browseBtn, &QPushButton::clicked, this, [this]() { + const QString dir = QFileDialog::getExistingDirectory( + this, tr("Zielordner wählen"), m_dirEdit->text()); + if (!dir.isEmpty()) + m_dirEdit->setText(dir); + }); + dirLayout->addWidget(browseBtn); + layout->addLayout(dirLayout); + + auto* btnLayout = new QHBoxLayout; + m_downloadBtn = new QPushButton(tr("Herunterladen && verifizieren")); + m_downloadBtn->setEnabled(false); + connect(m_downloadBtn, &QPushButton::clicked, this, &MainWindow::onDownloadClicked); + btnLayout->addWidget(m_downloadBtn); + m_cancelBtn = new QPushButton(tr("Abbrechen")); + m_cancelBtn->setEnabled(false); + connect(m_cancelBtn, &QPushButton::clicked, m_downloader, &DownloadManager::cancel); + btnLayout->addWidget(m_cancelBtn); + layout->addLayout(btnLayout); + + m_progress = new QProgressBar; + m_progress->setRange(0, 100); + m_progress->setValue(0); + layout->addWidget(m_progress); + + m_log = new QPlainTextEdit; + m_log->setReadOnly(true); + layout->addWidget(m_log, 1); + + splitter->addWidget(right); + splitter->setStretchFactor(0, 0); + splitter->setStretchFactor(1, 1); + splitter->setSizes({320, 780}); + setCentralWidget(splitter); + statusBar()->showMessage(tr("Bereit")); +} + +void MainWindow::log(const QString& msg) +{ + m_log->appendPlainText(QStringLiteral("[%1] %2") + .arg(QTime::currentTime().toString(QStringLiteral("HH:mm:ss")), msg)); +} + +const Distro* MainWindow::currentDistro() const +{ + QTreeWidgetItem* item = m_tree->currentItem(); + if (!item) + return nullptr; + const QString id = item->data(0, Qt::UserRole).toString(); + if (id.isEmpty()) + return nullptr; + for (const Distro& d : DistroRegistry::all()) + if (d.id == id) + return &d; + return nullptr; +} + +void MainWindow::resetResolvedState() +{ + m_hasResolved = false; + m_resolved = {}; + m_versionLabel->setText(QStringLiteral("—")); + m_urlLabel->setText(QStringLiteral("—")); + m_hashLabel->setText(QStringLiteral("—")); + m_hashLabel->setStyleSheet({}); + m_downloadBtn->setEnabled(false); + m_progress->setValue(0); +} + +void MainWindow::onSelectionChanged() +{ + m_resolver->abort(); + resetResolvedState(); + const Distro* d = currentDistro(); + m_resolveBtn->setEnabled(d != nullptr && !m_downloader->isActive()); + if (!d) { + m_titleLabel->setText(tr("Bitte eine Distribution auswählen.")); + m_notesLabel->hide(); + return; + } + m_titleLabel->setText(d->name); + m_notesLabel->setVisible(!d->notes.isEmpty()); + m_notesLabel->setText(d->notes.isEmpty() ? QString() + : tr("Hinweis: %1").arg(d->notes)); +} + +void MainWindow::onResolveClicked() +{ + const Distro* d = currentDistro(); + if (!d) + return; + resetResolvedState(); + m_resolveBtn->setEnabled(false); + log(tr("Löse Download-Link für %1 auf …").arg(d->name)); + m_resolver->resolve(*d); +} + +void MainWindow::onResolved(const ResolvedIso& iso) +{ + m_resolved = iso; + m_hasResolved = true; + m_resolveBtn->setEnabled(true); + m_versionLabel->setText(iso.version.isEmpty() ? tr("aktuell (rolling/latest)") : iso.version); + m_urlLabel->setText(iso.url); + if (iso.expectedHash.isEmpty()) { + m_hashLabel->setText(tr("KEINE Prüfsumme verfügbar — Verifikation nicht möglich!")); + m_hashLabel->setStyleSheet(QStringLiteral("color: #cc2222; font-weight: bold;")); + } else { + m_hashLabel->setText(QStringLiteral("%1: %2") + .arg(Resolver::algoName(iso.algo), iso.expectedHash)); + m_hashLabel->setStyleSheet({}); + } + m_downloadBtn->setEnabled(!m_downloader->isActive()); + log(tr("Aufgelöst: %1").arg(iso.url)); + statusBar()->showMessage(tr("Link aufgelöst: %1").arg(iso.fileName)); +} + +void MainWindow::onResolveFailed(const QString& error) +{ + m_resolveBtn->setEnabled(currentDistro() != nullptr); + log(tr("Fehler: %1").arg(error)); + statusBar()->showMessage(tr("Auflösung fehlgeschlagen")); +} + +void MainWindow::onDownloadClicked() +{ + if (!m_hasResolved || m_downloader->isActive()) + return; + + if (m_resolved.url.startsWith(QStringLiteral("http://"))) { + if (QMessageBox::warning(this, tr("Unverschlüsselter Download"), + tr("Dieser Download nutzt unverschlüsseltes HTTP und kann " + "unterwegs manipuliert werden. Trotzdem fortfahren?"), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) != QMessageBox::Yes) + return; + } + if (m_resolved.expectedHash.isEmpty()) { + if (QMessageBox::warning(this, tr("Keine Prüfsumme"), + tr("Für dieses ISO ist keine Prüfsumme verfügbar — die Datei " + "kann nicht automatisch verifiziert werden. Fortfahren?"), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) != QMessageBox::Yes) + return; + } + + const QString target = m_dirEdit->text() + QLatin1Char('/') + m_resolved.fileName; + if (QFile::exists(target)) { + if (QMessageBox::question(this, tr("Datei existiert"), + tr("%1 existiert bereits. Überschreiben?").arg(target)) + != QMessageBox::Yes) + return; + } + + m_downloadBtn->setEnabled(false); + m_resolveBtn->setEnabled(false); + m_cancelBtn->setEnabled(true); + log(tr("Starte Download nach %1").arg(target)); + m_downloader->start(m_resolved.url, target, m_resolved.algo, m_resolved.expectedHash); +} + +void MainWindow::onDownloadProgress(qint64 received, qint64 total) +{ + if (total > 0) { + m_progress->setRange(0, 100); + m_progress->setValue(static_cast(received * 100 / total)); + statusBar()->showMessage(tr("%1 / %2 MiB") + .arg(received / 1048576.0, 0, 'f', 1) + .arg(total / 1048576.0, 0, 'f', 1)); + } else { + m_progress->setRange(0, 0); // unbestimmt + statusBar()->showMessage(tr("%1 MiB").arg(received / 1048576.0, 0, 'f', 1)); + } +} + +void MainWindow::onDownloadFinished(bool ok, bool hashChecked, bool hashOk, + const QString& message) +{ + m_cancelBtn->setEnabled(false); + m_resolveBtn->setEnabled(currentDistro() != nullptr); + m_downloadBtn->setEnabled(m_hasResolved); + m_progress->setRange(0, 100); + m_progress->setValue(ok ? 100 : 0); + log(message); + + if (ok && hashChecked && hashOk) { + statusBar()->showMessage(tr("Fertig — Prüfsumme OK")); + QMessageBox::information(this, tr("Erfolg"), message); + } else if (ok) { + statusBar()->showMessage(tr("Fertig — NICHT verifiziert")); + QMessageBox::warning(this, tr("Nicht verifiziert"), message); + } else if (hashChecked && !hashOk) { + statusBar()->showMessage(tr("PRÜFSUMMENFEHLER")); + QMessageBox::critical(this, tr("Prüfsummenfehler"), message); + } else { + statusBar()->showMessage(tr("Download fehlgeschlagen")); + } +} diff --git a/src/MainWindow.h b/src/MainWindow.h new file mode 100644 index 0000000..733f389 --- /dev/null +++ b/src/MainWindow.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include "Distro.h" +#include "Resolver.h" + +class QTreeWidget; +class QTreeWidgetItem; +class QLabel; +class QLineEdit; +class QPushButton; +class QProgressBar; +class QPlainTextEdit; +class QNetworkAccessManager; +class DownloadManager; + +class MainWindow : public QMainWindow { + Q_OBJECT +public: + explicit MainWindow(QWidget* parent = nullptr); + +private slots: + void onSelectionChanged(); + void onResolveClicked(); + void onResolved(const ResolvedIso& iso); + void onResolveFailed(const QString& error); + void onDownloadClicked(); + void onDownloadProgress(qint64 received, qint64 total); + void onDownloadFinished(bool ok, bool hashChecked, bool hashOk, const QString& message); + +private: + void buildUi(); + void log(const QString& msg); + const Distro* currentDistro() const; + void resetResolvedState(); + + QNetworkAccessManager* m_nam = nullptr; + Resolver* m_resolver = nullptr; + DownloadManager* m_downloader = nullptr; + + QTreeWidget* m_tree = nullptr; + QLabel* m_titleLabel = nullptr; + QLabel* m_notesLabel = nullptr; + QLabel* m_versionLabel = nullptr; + QLabel* m_urlLabel = nullptr; + QLabel* m_hashLabel = nullptr; + QLineEdit* m_dirEdit = nullptr; + QPushButton* m_resolveBtn = nullptr; + QPushButton* m_downloadBtn = nullptr; + QPushButton* m_cancelBtn = nullptr; + QProgressBar* m_progress = nullptr; + QPlainTextEdit* m_log = nullptr; + + ResolvedIso m_resolved; + bool m_hasResolved = false; +}; diff --git a/src/Resolver.cpp b/src/Resolver.cpp new file mode 100644 index 0000000..ceddf94 --- /dev/null +++ b/src/Resolver.cpp @@ -0,0 +1,307 @@ +#include "Resolver.h" + +#include +#include +#include +#include +#include + +Resolver::Resolver(QNetworkAccessManager* nam, QObject* parent) + : QObject(parent), m_nam(nam) {} + +QString Resolver::algoName(QCryptographicHash::Algorithm a) +{ + switch (a) { + case QCryptographicHash::Md5: return QStringLiteral("MD5"); + case QCryptographicHash::Sha1: return QStringLiteral("SHA-1"); + case QCryptographicHash::Sha256: return QStringLiteral("SHA-256"); + case QCryptographicHash::Sha512: return QStringLiteral("SHA-512"); + default: return QStringLiteral("Hash"); + } +} + +void Resolver::resolve(const Distro& d) +{ + ++m_gen; + m_d = d; + m_versionCandidates.clear(); + m_version.clear(); + m_isoUrl.clear(); + m_fileName.clear(); + m_hash.clear(); + stepVersion(); +} + +void Resolver::abort() +{ + ++m_gen; +} + +QString Resolver::subst(QString s) const +{ + s.replace(QStringLiteral("%VERSION%"), m_version); + s.replace(QStringLiteral("%ISOURL%"), m_isoUrl); + s.replace(QStringLiteral("%FILENAME%"), m_fileName); + return s; +} + +void Resolver::fetch(const QString& url, + std::function cb) +{ + QNetworkRequest req{QUrl(url)}; + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + req.setHeader(QNetworkRequest::UserAgentHeader, + QStringLiteral("glorious-iso-dl/1.0")); + QNetworkReply* reply = m_nam->get(req); + const int gen = m_gen; + connect(reply, &QNetworkReply::finished, this, [this, reply, cb, url, gen]() { + reply->deleteLater(); + if (gen != m_gen) + return; // veralteter Aufruf + if (reply->error() != QNetworkReply::NoError) + cb({}, tr("%1: %2").arg(url, reply->errorString())); + else + cb(QString::fromUtf8(reply->readAll()), {}); + }); +} + +QStringList Resolver::allMatches(const QString& body, const QString& pattern, int group) +{ + QStringList out; + QRegularExpression re(pattern); + auto it = re.globalMatch(body); + while (it.hasNext()) { + const QString m = it.next().captured(group); + if (!m.isEmpty() && !out.contains(m)) + out << m; + } + return out; +} + +int Resolver::versionCompare(const QString& a, const QString& b) +{ + // Vergleicht versionsbewusst: Ziffernfolgen numerisch, Rest lexikalisch. + static const QRegularExpression tok(QStringLiteral("(\\d+|\\D+)")); + auto tokens = [](const QString& s) { + QStringList t; + auto it = tok.globalMatch(s); + while (it.hasNext()) + t << it.next().captured(1); + return t; + }; + const QStringList ta = tokens(a), tb = tokens(b); + const int n = qMin(ta.size(), tb.size()); + for (int i = 0; i < n; ++i) { + bool na = false, nb = false; + const qlonglong va = ta[i].toLongLong(&na); + const qlonglong vb = tb[i].toLongLong(&nb); + if (na && nb) { + if (va != vb) + return va < vb ? -1 : 1; + } else { + const int c = QString::compare(ta[i], tb[i]); + if (c != 0) + return c; + } + } + return ta.size() - tb.size(); +} + +QString Resolver::pickHighest(QStringList list) +{ + std::sort(list.begin(), list.end(), + [](const QString& a, const QString& b) { return versionCompare(a, b) < 0; }); + return list.isEmpty() ? QString() : list.last(); +} + +QString Resolver::parseChecksumFile(const QString& body, const QString& fileName, + QCryptographicHash::Algorithm algo) +{ + const int len = QCryptographicHash::hashLength(algo) * 2; + const QString base = QUrl(fileName).fileName(); + const QString algoKey = algoName(algo).remove(QLatin1Char('-')); // "SHA256" usw. + + const QRegularExpression lineStd( + QStringLiteral("^\\s*([0-9a-fA-F]{%1})[\\s*]+\\*?(.+?)\\s*$").arg(len)); + const QRegularExpression lineBsd( + QStringLiteral("^\\s*([A-Za-z0-9-]+)\\s*\\((.+)\\)\\s*=\\s*([0-9a-fA-F]{%1})\\s*$").arg(len)); + + QString sectionLabel; // letzter Kommentar (z. B. "# SHA512 HASH" bei Gentoo) + QStringList bareHashes; + QStringList lineHashes; // alle Hashes aus Zeilen mit (fremdem) Dateinamen + + for (const QString& raw : body.split(QLatin1Char('\n'))) { + const QString line = raw.trimmed(); + if (line.startsWith(QLatin1Char('#'))) { + sectionLabel = line.toUpper(); + continue; + } + // Abschnitt gehört erkennbar zu einem anderen Algorithmus -> überspringen + if (!sectionLabel.isEmpty() && sectionLabel.contains(QStringLiteral("HASH")) + && !sectionLabel.contains(algoKey)) + continue; + + if (auto m = lineBsd.match(line); m.hasMatch()) { + // BSD-Format: "SHA256 (datei.iso) = hash" + const QString label = m.captured(1).toUpper().remove(QLatin1Char('-')); + const QString name = QUrl(m.captured(2).trimmed()).fileName(); + if (label == algoKey && (name == base || base.isEmpty())) + return m.captured(3).toLower(); + continue; + } + if (auto m = lineStd.match(line); m.hasMatch()) { + const QString name = QUrl(m.captured(2).trimmed()).fileName(); + if (name == base || (base.isEmpty() && !name.isEmpty())) + return m.captured(1).toLower(); + lineHashes << m.captured(1).toLower(); + continue; + } + // Zeile besteht nur aus einem Hash (typisch für *.sha256-Einzeldateien) + static const QRegularExpression bareRe(QStringLiteral("^[0-9a-fA-F]+$")); + if (line.size() == len && bareRe.match(line).hasMatch()) + bareHashes << line.toLower(); + } + // Fallback 1: genau ein nackter Hash in der Datei + if (bareHashes.size() == 1) + return bareHashes.first(); + // Fallback 2: genau eine Hash-Zeile, deren Dateiname nicht passt — typisch + // für "latest.iso"-Aliasse, bei denen die Prüfsummendatei den echten Namen führt + if (bareHashes.isEmpty() && lineHashes.size() == 1) + return lineHashes.first(); + return {}; +} + +void Resolver::stepVersion() +{ + if (m_d.versionUrl.isEmpty()) { + stepPage(); + return; + } + emit progress(tr("Ermittle neueste Version …")); + fetch(subst(m_d.versionUrl), [this](const QString& body, const QString& err) { + if (!err.isEmpty()) { emit failed(err); return; } + QStringList found = allMatches(body, m_d.versionRegex, 1); + if (found.isEmpty()) { + emit failed(tr("Keine Version gefunden auf %1").arg(m_d.versionUrl)); + return; + } + std::sort(found.begin(), found.end(), + [](const QString& a, const QString& b) { return versionCompare(a, b) > 0; }); + // Höchste Version zuerst; einige Reserven für unveröffentlichte Verzeichnisse + m_versionCandidates = found.mid(0, 6); + m_version = m_versionCandidates.takeFirst(); + emit progress(tr("Neueste Version: %1").arg(m_version)); + stepPage(); + }); +} + +bool Resolver::retryNextVersion() +{ + if (m_versionCandidates.isEmpty()) + return false; + m_version = m_versionCandidates.takeFirst(); + m_isoUrl.clear(); + m_fileName.clear(); + m_hash.clear(); + emit progress(tr("Noch nicht veröffentlicht — versuche Version %1 …").arg(m_version)); + stepPage(); + return true; +} + +void Resolver::stepPage() +{ + if (m_d.pageUrl.isEmpty()) { + stepChecksum(); + return; + } + emit progress(tr("Suche ISO-Link …")); + const QString pageUrl = subst(m_d.pageUrl); + fetch(pageUrl, [this, pageUrl](const QString& body, const QString& err) { + if (!err.isEmpty()) { + if (retryNextVersion()) + return; + emit failed(err); + return; + } + + if (!m_d.linkRegex.isEmpty()) { + const QStringList links = allMatches(body, subst(m_d.linkRegex), 1); + if (links.isEmpty()) { + if (retryNextVersion()) + return; + emit failed(tr("Kein ISO-Link gefunden auf %1").arg(pageUrl)); + return; + } + const QString link = pickHighest(links); + m_isoUrl = QUrl(pageUrl).resolved(QUrl(link)).toString(); + m_fileName = QUrl(m_isoUrl).fileName(); + } + if (!m_d.checksumLinkRegex.isEmpty()) { + const QStringList cs = allMatches(body, subst(m_d.checksumLinkRegex), 1); + if (!cs.isEmpty()) + m_d.checksumUrl = QUrl(pageUrl).resolved(QUrl(pickHighest(cs))).toString(); + } + if (!m_d.hashRegex.isEmpty()) { + const QStringList h = allMatches(body, subst(m_d.hashRegex), 1); + if (!h.isEmpty()) + m_hash = h.first().toLower(); + } + stepChecksum(); + }); +} + +void Resolver::stepChecksum() +{ + if (m_isoUrl.isEmpty() && !m_d.isoUrl.isEmpty()) { + m_isoUrl = subst(m_d.isoUrl); + m_fileName = QUrl(m_isoUrl).fileName(); + } + if (!m_hash.isEmpty()) { finish(); return; } + if (m_d.checksumUrl.isEmpty()) { finish(); return; } + + emit progress(tr("Lade Prüfsummen …")); + fetch(subst(m_d.checksumUrl), [this](const QString& body, const QString& err) { + if (!err.isEmpty()) { + if (retryNextVersion()) + return; + emit failed(tr("Prüfsummendatei nicht erreichbar: %1").arg(err)); + return; + } + if (!m_d.isoPattern.isEmpty()) { + // Listenmodus: Dateiname aus der Prüfsummendatei ermitteln + const QStringList files = allMatches( + body, QStringLiteral("(%1)").arg(subst(m_d.isoPattern)), 1); + if (files.isEmpty()) { + if (retryNextVersion()) + return; + emit failed(tr("Kein passendes ISO in der Prüfsummendatei gefunden.")); + return; + } + m_fileName = pickHighest(files); + m_isoUrl = subst(m_d.downloadBase) + m_fileName; + } + m_hash = parseChecksumFile(body, m_fileName, m_d.algo); + if (m_hash.isEmpty()) { + emit failed(tr("Kein %1-Hash für %2 in der Prüfsummendatei gefunden.") + .arg(algoName(m_d.algo), m_fileName)); + return; + } + finish(); + }); +} + +void Resolver::finish() +{ + if (m_isoUrl.isEmpty()) { + emit failed(tr("ISO-URL konnte nicht ermittelt werden.")); + return; + } + ResolvedIso r; + r.url = m_isoUrl; + r.fileName = m_fileName.isEmpty() ? QUrl(m_isoUrl).fileName() : m_fileName; + r.version = m_version; + r.expectedHash = m_hash; + r.algo = m_d.algo; + emit resolved(r); +} diff --git a/src/Resolver.h b/src/Resolver.h new file mode 100644 index 0000000..626ad61 --- /dev/null +++ b/src/Resolver.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include "Distro.h" + +class QNetworkAccessManager; + +struct ResolvedIso { + QString url; + QString fileName; + QString version; + QString expectedHash; // hex, klein; leer = keine Prüfsumme verfügbar + QCryptographicHash::Algorithm algo = QCryptographicHash::Sha256; +}; + +class Resolver : public QObject { + Q_OBJECT +public: + explicit Resolver(QNetworkAccessManager* nam, QObject* parent = nullptr); + + void resolve(const Distro& d); + void abort(); + + static QString algoName(QCryptographicHash::Algorithm a); + +signals: + void progress(const QString& msg); + void resolved(const ResolvedIso& iso); + void failed(const QString& error); + +private: + void stepVersion(); + void stepPage(); + void stepChecksum(); + void finish(); + + void fetch(const QString& url, + std::function cb); + QString subst(QString s) const; + + static QStringList allMatches(const QString& body, const QString& pattern, int group); + static QString pickHighest(QStringList list); + static int versionCompare(const QString& a, const QString& b); + static QString parseChecksumFile(const QString& body, const QString& fileName, + QCryptographicHash::Algorithm algo); + + bool retryNextVersion(); // Fallback: nächstniedrigere Version probieren + + QNetworkAccessManager* m_nam; + Distro m_d; + int m_gen = 0; // entwertet Callbacks älterer resolve()-Aufrufe + QStringList m_versionCandidates; // absteigend sortiert + QString m_version; + QString m_isoUrl; + QString m_fileName; + QString m_hash; +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..7c71d55 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,13 @@ +#include +#include "MainWindow.h" + +int main(int argc, char* argv[]) +{ + QApplication app(argc, argv); + QApplication::setApplicationName(QStringLiteral("glorious-iso-dl")); + QApplication::setApplicationVersion(QStringLiteral("1.0")); + + MainWindow win; + win.show(); + return app.exec(); +}