From f64f74f0d8887c3d312088daf4e95fe0db1f41ad Mon Sep 17 00:00:00 2001
From: TableFlipper9 <hithack9@gmail.com>
Date: Fri, 1 Aug 2025 12:00:16 +0300
Subject: [PATCH] Calamares 3.3.14: introducing Gentoo Stage3 Chooser and
 respective modules

* introduced stage3 choosing module
* created boxes to choose mirrors, arches and stage3 archives
* introduced simple warning to new user regarding
  blank mirror, which means default mirror
* error showing for faild fetches or network problems

Signed-off-by: Morovan Mihai <mihaimorovan1@gmail.com>
---
 calamares-pkexec                              |   7 +
 src/modules/downloadstage3/main.py            | 182 ++++++++
 src/modules/downloadstage3/module.desc        |   7 +
 src/modules/dracut_gentoo/main.py             |  63 +++
 src/modules/dracut_gentoo/module.desc         |   7 +
 src/modules/gentoopkg/gentoopkg.conf          |  83 ++++
 src/modules/gentoopkg/gentoopkg.schema.yaml   |  36 ++
 src/modules/gentoopkg/main.py                 | 432 ++++++++++++++++++
 src/modules/gentoopkg/module.desc             |   9 +
 .../gentoopkg/test-skip-unavailable.conf      |  32 ++
 src/modules/stagechoose/CMakeLists.txt        |  13 +
 src/modules/stagechoose/Config.cpp            | 128 ++++++
 src/modules/stagechoose/Config.h              |  65 +++
 src/modules/stagechoose/SetStage3Job.cpp      |  61 +++
 src/modules/stagechoose/SetStage3Job.h        |  22 +
 src/modules/stagechoose/StageChoosePage.cpp   | 145 ++++++
 src/modules/stagechoose/StageChoosePage.h     |  51 +++
 src/modules/stagechoose/StageChoosePage.ui    | 172 +++++++
 .../stagechoose/StageChooseViewStep.cpp       |  80 ++++
 src/modules/stagechoose/StageChooseViewStep.h |  53 +++
 src/modules/stagechoose/StageFetcher.cpp      | 152 ++++++
 src/modules/stagechoose/StageFetcher.h        |  42 ++
 src/modules/stagechoose/stagechoose.conf      |   7 +
 .../stagechoose/stagechoose.schema.yaml       |  17 +
 24 files changed, 1866 insertions(+)
 create mode 100755 calamares-pkexec
 create mode 100644 src/modules/downloadstage3/main.py
 create mode 100644 src/modules/downloadstage3/module.desc
 create mode 100644 src/modules/dracut_gentoo/main.py
 create mode 100644 src/modules/dracut_gentoo/module.desc
 create mode 100644 src/modules/gentoopkg/gentoopkg.conf
 create mode 100644 src/modules/gentoopkg/gentoopkg.schema.yaml
 create mode 100644 src/modules/gentoopkg/main.py
 create mode 100644 src/modules/gentoopkg/module.desc
 create mode 100644 src/modules/gentoopkg/test-skip-unavailable.conf
 create mode 100644 src/modules/stagechoose/CMakeLists.txt
 create mode 100644 src/modules/stagechoose/Config.cpp
 create mode 100644 src/modules/stagechoose/Config.h
 create mode 100644 src/modules/stagechoose/SetStage3Job.cpp
 create mode 100644 src/modules/stagechoose/SetStage3Job.h
 create mode 100644 src/modules/stagechoose/StageChoosePage.cpp
 create mode 100644 src/modules/stagechoose/StageChoosePage.h
 create mode 100644 src/modules/stagechoose/StageChoosePage.ui
 create mode 100644 src/modules/stagechoose/StageChooseViewStep.cpp
 create mode 100644 src/modules/stagechoose/StageChooseViewStep.h
 create mode 100644 src/modules/stagechoose/StageFetcher.cpp
 create mode 100644 src/modules/stagechoose/StageFetcher.h
 create mode 100644 src/modules/stagechoose/stagechoose.conf
 create mode 100644 src/modules/stagechoose/stagechoose.schema.yaml

diff --git a/calamares-pkexec b/calamares-pkexec
new file mode 100755
index 0000000000..16334252a9
--- /dev/null
+++ b/calamares-pkexec
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+main() {
+	pkexec /usr/bin/calamares
+}
+
+main
diff --git a/src/modules/downloadstage3/main.py b/src/modules/downloadstage3/main.py
new file mode 100644
index 0000000000..9ea50a0841
--- /dev/null
+++ b/src/modules/downloadstage3/main.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+import os
+import shutil
+import urllib.request
+import tarfile
+import subprocess
+import libcalamares
+import glob
+import re
+import sys
+import time
+
+def _progress_hook(count, block_size, total_size):
+    _check_parent_alive()
+    percent = int(count * block_size * 100 / total_size)
+    if percent > 100:
+        percent = 100
+    libcalamares.job.setprogress(percent / 2)
+
+def _check_parent_alive():
+    if os.getppid() == 1:
+        sys.exit(1)
+
+def _check_global_storage_keys():
+    """Check if required global storage keys are set and have values."""
+    print("Checking global storage keys...")
+    
+    if not libcalamares.globalstorage.contains("FINAL_DOWNLOAD_URL"):
+        raise Exception("FINAL_DOWNLOAD_URL key is not set in global storage")
+    
+    if not libcalamares.globalstorage.contains("STAGE_NAME_TAR"):
+        raise Exception("STAGE_NAME_TAR key is not set in global storage")
+    
+    final_download_url = libcalamares.globalstorage.value("FINAL_DOWNLOAD_URL")
+    stage_name_tar = libcalamares.globalstorage.value("STAGE_NAME_TAR")
+    
+    if final_download_url.endswith('/'):
+        final_download_url = final_download_url.rstrip('/')
+    
+    if not final_download_url:
+        raise Exception("FINAL_DOWNLOAD_URL key exists but has no value")
+    
+    if not stage_name_tar:
+        raise Exception("STAGE_NAME_TAR key exists but has no value")
+    
+    print(f"FINAL_DOWNLOAD_URL variable: {final_download_url}")
+    print(f"STAGE_NAME_TAR variable: {stage_name_tar}")
+    
+    return final_download_url, stage_name_tar
+
+def _safe_run(cmd):
+    _check_parent_alive()
+    try:
+        proc = subprocess.Popen(cmd)
+        while True:
+            retcode = proc.poll()
+            if retcode is not None:
+                if retcode != 0:
+                    sys.exit(1) 
+                return retcode
+            if os.getppid() == 1:
+                proc.terminate()
+                try:
+                    proc.wait(timeout=5)
+                except subprocess.TimeoutExpired:
+                    proc.kill()
+                sys.exit(1)
+            time.sleep(1)
+    except subprocess.SubprocessError:
+        sys.exit(1)
+
+def run():
+    if (libcalamares.globalstorage.contains("GENTOO_LIVECD") and 
+        libcalamares.globalstorage.value("GENTOO_LIVECD") == "yes"):
+        print("GENTOO_LIVECD is set to 'yes', mounting /run/rootfsbase over /mnt/gentoo-rootfs")
+        extract_path = "/mnt/gentoo-rootfs"
+        
+        os.makedirs(extract_path, exist_ok=True)
+        
+        _safe_run(["mount", "--bind", "/run/rootfsbase", extract_path])
+        
+        return None
+
+    # Check if required global storage keys are set
+    final_download_url, stage_name_tar = _check_global_storage_keys()
+    
+    # FINAL_DOWNLOAD_URL contains the complete file URL, so use it directly
+    full_tarball_url = final_download_url
+    full_sha256_url = final_download_url + ".sha256"
+
+    download_path = f"/mnt/{stage_name_tar}"
+    sha256_path = f"/mnt/{stage_name_tar}.sha256"
+    extract_path = "/mnt/gentoo-rootfs"
+
+    if os.path.exists(extract_path):
+        for entry in os.listdir(extract_path):
+            path = os.path.join(extract_path, entry)
+            if os.path.isfile(path) or os.path.islink(path):
+                os.unlink(path)
+            elif os.path.isdir(path):
+                shutil.rmtree(path)
+    else:
+        os.makedirs(extract_path, exist_ok=True)
+
+    if os.path.exists(download_path):
+        os.remove(download_path)
+    if os.path.exists(sha256_path):
+        os.remove(sha256_path)
+
+    urllib.request.urlretrieve(full_tarball_url, download_path, _progress_hook)
+    libcalamares.job.setprogress(40)
+    urllib.request.urlretrieve(full_sha256_url, sha256_path)
+    libcalamares.job.setprogress(50)
+
+    _safe_run(["bash", "-c", f"cd /mnt && sha256sum -c {stage_name_tar}.sha256"])
+
+    with tarfile.open(download_path, "r:xz") as tar:
+        members = tar.getmembers()
+        total_members = len(members)
+        for i, member in enumerate(members):
+            _check_parent_alive()
+            tar.extract(member, extract_path)
+            libcalamares.job.setprogress(50 + (i * 50 / total_members))
+
+    os.remove(download_path)
+    os.remove(sha256_path)
+
+    shutil.copy2("/etc/resolv.conf", os.path.join(extract_path, "etc", "resolv.conf"))
+    os.makedirs(os.path.join(extract_path, "etc/portage/binrepos.conf"), exist_ok=True)
+    
+    gentoobinhost_source = "/etc/portage/binrepos.conf/gentoobinhost.conf"
+    if os.path.exists(gentoobinhost_source):
+        shutil.copy2(
+            gentoobinhost_source,
+            os.path.join(extract_path, "etc/portage/binrepos.conf/gentoobinhost.conf")
+        )
+    else:
+        print(f"Warning: {gentoobinhost_source} does not exist, skipping copy")
+
+    _safe_run(["chroot", extract_path, "getuto"])
+
+    package_use_dir = os.path.join(extract_path, "etc/portage/package.use")
+    os.makedirs(package_use_dir, exist_ok=True)
+    with open(os.path.join(package_use_dir, "00-livecd.package.use"), "w", encoding="utf-8") as f:
+        f.write(">=sys-kernel/installkernel-50 dracut\n")
+
+
+    _safe_run(["mount", "--bind", "/proc", os.path.join(extract_path, "proc")])
+    _safe_run(["mount", "--bind", "/sys", os.path.join(extract_path, "sys")])
+    _safe_run(["mount", "--bind", "/dev", os.path.join(extract_path, "dev")])
+    _safe_run(["mount", "--bind", "/run", os.path.join(extract_path, "run")])
+
+    _safe_run([
+        "chroot", extract_path, "/bin/bash", "-c",
+        'emerge-webrsync -q'
+    ])
+
+    _safe_run([
+        "chroot", extract_path, "/bin/bash", "-c",
+        'EMERGE_DEFAULT_OPTS="${EMERGE_DEFAULT_OPTS} --getbinpkg" emerge -q sys-apps/dbus sys-boot/grub'
+    ])
+
+    _safe_run([
+        "chroot", extract_path, "/bin/bash", "-c",
+        'EMERGE_DEFAULT_OPTS="${EMERGE_DEFAULT_OPTS} --getbinpkg" emerge -q1 timezone-data'
+    ])
+
+    for folder in ["distfiles", "binpkgs"]:
+        path = os.path.join(extract_path, f"var/cache/{folder}")
+        if os.path.exists(path):
+            for entry in glob.glob(path + "/*"):
+                if os.path.isfile(entry) or os.path.islink(entry):
+                    os.unlink(entry)
+                elif os.path.isdir(entry):
+                    shutil.rmtree(entry)
+
+    _safe_run(["umount", "-l", os.path.join(extract_path, "proc")])
+    _safe_run(["umount", "-l", os.path.join(extract_path, "sys")])
+    _safe_run(["umount", "-l", os.path.join(extract_path, "dev")])
+    _safe_run(["umount", "-l", os.path.join(extract_path, "run")])
+
+    return None
diff --git a/src/modules/downloadstage3/module.desc b/src/modules/downloadstage3/module.desc
new file mode 100644
index 0000000000..41c2ddbb6a
--- /dev/null
+++ b/src/modules/downloadstage3/module.desc
@@ -0,0 +1,7 @@
+# SPDX-FileCopyrightText: no
+# SPDX-License-Identifier: CC0-1.0
+---
+type:       "job"
+name:       "downloadstage3"
+interface:  "python"
+script:     "main.py"
diff --git a/src/modules/dracut_gentoo/main.py b/src/modules/dracut_gentoo/main.py
new file mode 100644
index 0000000000..fc491cb594
--- /dev/null
+++ b/src/modules/dracut_gentoo/main.py
@@ -0,0 +1,63 @@
+import subprocess
+import glob
+import os
+import re
+import libcalamares
+from libcalamares.utils import target_env_process_output
+
+def find_latest_gentoo_initramfs():
+    root_mount_point = libcalamares.globalstorage.value("rootMountPoint")
+    if not root_mount_point:
+        raise ValueError("rootMountPoint not set in global storage")
+    
+    target_boot_path = os.path.join(root_mount_point, 'boot')
+    search_pattern = os.path.join(target_boot_path, 'initramfs-*-gentoo-dist.img')
+    candidates = glob.glob(search_pattern)
+    
+    if not candidates:
+        raise FileNotFoundError(f"No Gentoo dist initramfs found in {target_boot_path}")
+
+    def extract_version(path):
+        basename = os.path.basename(path)
+        match = re.search(r'initramfs-(\d+\.\d+\.\d+)-gentoo-dist\.img', basename)
+        if match:
+            return tuple(map(int, match.group(1).split('.')))
+        return (0, 0, 0)
+
+    candidates.sort(key=lambda x: extract_version(x), reverse=True)
+    return candidates[0]
+
+def extract_kernel_simple_version(initramfs_path):
+    basename = os.path.basename(initramfs_path)
+    match = re.search(r'initramfs-(\d+\.\d+\.\d+)-gentoo-dist\.img', basename)
+    if match:
+        return match.group(1)
+    raise ValueError(f"Could not extract simple version from initramfs filename: {basename}")
+
+def run():
+    try:
+        dracut_options = [
+            "-H", "-f",
+            "-o", "systemd", "-o", "systemd-initrd", "-o", "systemd-networkd", 
+            "-o", "dracut-systemd", "-o", "plymouth",
+            "--early-microcode"
+        ]
+        
+        latest_initramfs = find_latest_gentoo_initramfs()
+        simple_version = extract_kernel_simple_version(latest_initramfs)
+        dracut_options.append(f'--kver={simple_version}-gentoo-dist')
+        
+        result = target_env_process_output(['dracut'] + dracut_options)
+        libcalamares.utils.debug(f"Successfully created initramfs for kernel {simple_version}-gentoo-dist")
+        
+    except FileNotFoundError as e:
+        libcalamares.utils.warning(f"No Gentoo initramfs found: {e}")
+        return 1
+    except ValueError as e:
+        libcalamares.utils.warning(f"Failed to extract kernel version: {e}")
+        return 1
+    except subprocess.CalledProcessError as cpe:
+        libcalamares.utils.warning(f"Dracut failed with output: {cpe.output}")
+        return cpe.returncode
+    
+    return None
\ No newline at end of file
diff --git a/src/modules/dracut_gentoo/module.desc b/src/modules/dracut_gentoo/module.desc
new file mode 100644
index 0000000000..f200063d97
--- /dev/null
+++ b/src/modules/dracut_gentoo/module.desc
@@ -0,0 +1,7 @@
+# SPDX-FileCopyrightText: no
+# SPDX-License-Identifier: CC0-1.0
+---
+type:       "job"
+name:       "dracut_gentoo"
+interface:  "python"
+script:     "main.py"
diff --git a/src/modules/gentoopkg/gentoopkg.conf b/src/modules/gentoopkg/gentoopkg.conf
new file mode 100644
index 0000000000..5490d45097
--- /dev/null
+++ b/src/modules/gentoopkg/gentoopkg.conf
@@ -0,0 +1,83 @@
+# SPDX-FileCopyrightText: no
+# SPDX-License-Identifier: CC0-1.0
+#
+# Configuration for the gentoopkg module - Gentoo-specific package management
+# This module extends the standard packages functionality with Gentoo-specific
+# features like USE flags, package.accept_keywords, and world updates.
+---
+
+# Skip package installation/removal failures instead of aborting Calamares.
+# When true, treats all "install" operations as "try_install" and
+# all "remove" operations as "try_remove", meaning package
+# installation/removal failures won't cause Calamares to fail.
+# This is useful when some packages might not be available in
+# certain repositories or USE flag configurations.
+skip_unavailable: false
+
+# Skip the whole module when there is no internet connection.
+skip_if_no_internet: false
+
+# Run "emerge-webrsync -q" to update the Portage tree before installing packages.
+update_db: true
+
+# Method to use for Portage tree sync when update_db is true.
+# Options:
+#  - "webrsync": Use emerge-webrsync (faster, uses snapshots, fallback to sync if fails)
+#  - "sync": Use emerge --sync (slower but more reliable for problematic networks)  
+#  - "none": Skip syncing entirely (use existing Portage tree)
+sync_method: webrsync
+
+# Run system update after package operations.
+# If gentoo_world_update is true, runs "emerge --update --deep --newuse @world"
+# If false, runs "emerge --update @system"
+update_system: false
+
+# Perform a full world update after package operations.
+# This ensures all dependencies are properly updated with new USE flags.
+gentoo_world_update: false
+
+# List of packages that should be added to package.accept_keywords
+# to allow installation of testing/unstable versions.
+# Example: ["dev-lang/rust", "sys-devel/llvm"]
+accept_keywords: []
+
+# Package operations - same format as the standard packages module
+# Supported operations:
+#  - install: Install packages (critical - will fail if package unavailable, unless skip_unavailable=true)
+#  - try_install: Install packages (non-critical - will continue if package unavailable)
+#  - remove: Remove packages (critical - will fail if package unavailable, unless skip_unavailable=true)  
+#  - try_remove: Remove packages (non-critical - will continue if package unavailable)
+#  - localInstall: Install local .tbz2 packages
+#
+# Package names support Gentoo-specific syntax:
+#  - Category/package: "dev-lang/python"
+#  - Specific versions: "=dev-lang/python-3.11*"
+#  - USE flags: "dev-lang/python[sqlite,ssl]"
+#  - Slots: "dev-lang/python:3.11"
+#
+# Localization with LOCALE substitution is supported:
+#  - "app-office/libreoffice-l10n-${LOCALE}" will be substituted based on system locale
+#  - If locale is "en", packages with LOCALE in the name are skipped
+#
+operations:
+  # Example: Install essential packages
+  - install:
+    - app-editors/vim
+    - sys-process/htop
+    - net-misc/wget
+    - app-admin/sudo
+  
+  # Example: Remove unwanted packages (use try_remove to ignore if not present)
+  - try_remove:
+    - games-misc/fortune-mod
+    
+  # Example: Install packages that might not be available everywhere
+  - try_install:
+    - app-office/libreoffice-l10n-${LOCALE}
+    - media-libs/mesa[vulkan]  # might fail if vulkan USE not available
+
+  # Example: Install with pre/post scripts
+  - install:
+    - package: www-servers/apache
+      pre-script: /bin/systemctl stop apache2 || true
+      post-script: /bin/systemctl enable apache2
diff --git a/src/modules/gentoopkg/gentoopkg.schema.yaml b/src/modules/gentoopkg/gentoopkg.schema.yaml
new file mode 100644
index 0000000000..cbce262f0a
--- /dev/null
+++ b/src/modules/gentoopkg/gentoopkg.schema.yaml
@@ -0,0 +1,36 @@
+# SPDX-FileCopyrightText: 2020 Adriaan de Groot <groot@kde.org>
+# SPDX-License-Identifier: GPL-3.0-or-later
+---
+$schema: https://json-schema.org/schema#
+$id: https://calamares.io/schemas/gentoopkg
+additionalProperties: false
+type: object
+properties:
+    skip_unavailable: { type: boolean, default: false }
+    skip_if_no_internet: { type: boolean, default: false }
+    update_db: { type: boolean, default: true }
+    sync_method: 
+        type: string
+        enum: ["webrsync", "sync", "none"]
+        default: "webrsync"
+    update_system: { type: boolean, default: false }
+    gentoo_world_update: { type: boolean, default: false }
+    
+    accept_keywords:
+        type: array
+        items:
+            type: string
+        default: []
+
+    operations:
+        type: array
+        items:
+            additionalProperties: false
+            type: object
+            properties:
+                install: { type: array }
+                remove: { type: array }
+                try_install: { type: array }
+                try_remove: { type: array }
+                localInstall: { type: array }
+                source: { type: string }
diff --git a/src/modules/gentoopkg/main.py b/src/modules/gentoopkg/main.py
new file mode 100644
index 0000000000..d19e02d60f
--- /dev/null
+++ b/src/modules/gentoopkg/main.py
@@ -0,0 +1,432 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# === This file is part of Calamares - <https://calamares.io> ===
+#
+#   SPDX-FileCopyrightText: 2014 Pier Luigi Fiorini <pierluigi.fiorini@gmail.com>
+#   SPDX-FileCopyrightText: 2015-2017 Teo Mrnjavac <teo@kde.org>
+#   SPDX-FileCopyrightText: 2016-2017 Kyle Robbertze <kyle@aims.ac.za>
+#   SPDX-FileCopyrightText: 2017 Alf Gaida <agaida@siduction.org>
+#   SPDX-FileCopyrightText: 2018 Adriaan de Groot <groot@kde.org>
+#   SPDX-FileCopyrightText: 2018 Philip Müller <philm@manjaro.org>
+#   SPDX-License-Identifier: GPL-3.0-or-later
+#
+#   Calamares is Free Software: see the License-Identifier above.
+#
+#   Gentoo-specific package manager module that extends the standard packages
+#   module with additional functionality:
+#   
+#   Configuration options:
+#   - skip_unavailable: boolean (default: false)
+#     If true, treats all "install" operations as "try_install" and
+#     all "remove" operations as "try_remove", meaning package
+#     installation/removal failures won't cause Calamares to fail.
+#     This is useful when some packages might not be available in
+#     certain repositories or USE flag configurations.
+#   - gentoo_world_update: boolean (default: false)
+#     If true, performs an "emerge --update --deep --newuse @world" 
+#     after package operations to ensure system consistency.
+#   - accept_keywords: list of strings (default: [])
+#     List of packages to add to package.accept_keywords before
+#     package installation (for testing/unstable packages).
+#   - sync_method: string (default: "webrsync")
+#     Method to use for Portage tree sync. Options: "webrsync", "sync", "none".
+#     "webrsync" uses emerge-webrsync (faster, uses snapshots),
+#     "sync" uses emerge --sync (slower but more reliable),
+#     "none" skips syncing entirely.
+#
+
+import abc
+from string import Template
+import subprocess
+import os
+
+import libcalamares
+from libcalamares.utils import check_target_env_call, target_env_call
+from libcalamares.utils import gettext_path, gettext_languages
+
+import gettext
+_translation = gettext.translation("calamares-python",
+                                   localedir=gettext_path(),
+                                   languages=gettext_languages(),
+                                   fallback=True)
+_ = _translation.gettext
+_n = _translation.ngettext
+
+# For the entire job
+total_packages = 0
+# Done so far for this job
+completed_packages = 0
+# One group of packages from an -install or -remove entry
+group_packages = 0
+
+# You can override the status message by setting this variable
+custom_status_message = None
+
+INSTALL = object()
+REMOVE = object()
+mode_packages = None
+
+
+def _change_mode(mode):
+    global mode_packages
+    mode_packages = mode
+    libcalamares.job.setprogress(completed_packages * 1.0 / total_packages)
+
+
+def pretty_name():
+    return _("Install Gentoo packages.")
+
+
+def pretty_status_message():
+    if custom_status_message is not None:
+        return custom_status_message
+    if not group_packages:
+        if (total_packages > 0):
+            s = _("Processing packages (%(count)d / %(total)d)")
+        else:
+            s = _("Install Gentoo packages.")
+
+    elif mode_packages is INSTALL:
+        s = _n("Installing one package.",
+               "Installing %(num)d packages.", group_packages)
+    elif mode_packages is REMOVE:
+        s = _n("Removing one package.",
+               "Removing %(num)d packages.", group_packages)
+    else:
+        s = _("Install Gentoo packages.")
+
+    return s % {"num": group_packages,
+                "count": completed_packages,
+                "total": total_packages}
+
+
+class GentooPackageManager:
+    """
+    Gentoo-specific package manager using Portage (emerge).
+    This extends the basic package management with Gentoo-specific
+    features like USE flags, package.accept_keywords, and world updates.
+    """
+    
+    def __init__(self):
+        self.skip_unavailable = libcalamares.job.configuration.get("skip_unavailable", False)
+        self.gentoo_world_update = libcalamares.job.configuration.get("gentoo_world_update", False)
+        self.accept_keywords = libcalamares.job.configuration.get("accept_keywords", [])
+        self.sync_method = libcalamares.job.configuration.get("sync_method", "webrsync")
+        
+        if self.accept_keywords:
+            self._setup_accept_keywords()
+    
+    def _setup_accept_keywords(self):
+        """Setup package.accept_keywords file for testing packages."""
+        keywords_dir = "/etc/portage/package.accept_keywords"
+        keywords_file = os.path.join(keywords_dir, "calamares-install")
+        
+        try:
+            target_keywords_dir = libcalamares.globalstorage.value("rootMountPoint") + keywords_dir
+            os.makedirs(target_keywords_dir, exist_ok=True)
+            
+            target_keywords_file = libcalamares.globalstorage.value("rootMountPoint") + keywords_file
+            with open(target_keywords_file, 'w') as f:
+                f.write("# Generated by Calamares gentoopkg module\n")
+                for package in self.accept_keywords:
+                    f.write(f"{package} ~amd64\n")
+            
+            libcalamares.utils.debug(f"Created {target_keywords_file} with {len(self.accept_keywords)} entries")
+        except Exception as e:
+            libcalamares.utils.warning(f"Could not setup package.accept_keywords: {e}")
+
+    def install(self, pkgs, from_local=False):
+        """Install packages using emerge."""
+        command = ["emerge", "--ask=n", "--verbose=y"]
+        
+        if from_local:
+            command.extend(pkgs)
+        else:
+            command.extend(pkgs)
+        
+        if self.skip_unavailable:
+            # Use --keep-going to continue even if some packages fail
+            command.append("--keep-going")
+        
+        check_target_env_call(command)
+
+    def remove(self, pkgs):
+        """Remove packages using emerge."""
+        command = ["emerge", "--ask=n", "--verbose=y", "--unmerge"]
+        command.extend(pkgs)
+        
+        if self.skip_unavailable:
+            # Use --keep-going for removals too
+            command.append("--keep-going")
+        
+        check_target_env_call(command)
+
+    def update_db(self):
+        """Sync the Portage tree using the configured method."""
+        if self.sync_method == "none":
+            libcalamares.utils.debug("Skipping Portage tree sync (sync_method=none)")
+            return
+        
+        if self.sync_method == "webrsync":
+            # Try emerge-webrsync first (faster, uses snapshots)
+            try:
+                libcalamares.utils.debug("Syncing Portage tree with emerge-webrsync...")
+                check_target_env_call(["emerge-webrsync", "-q"])
+                return
+            except subprocess.CalledProcessError as e:
+                libcalamares.utils.warning(f"emerge-webrsync failed (exit code {e.returncode}), trying emerge --sync as fallback...")
+        
+        if self.sync_method == "sync" or self.sync_method == "webrsync":
+            # Use regular sync (either explicitly requested or as fallback)
+            libcalamares.utils.debug("Syncing Portage tree with emerge --sync...")
+            check_target_env_call(["emerge", "--sync", "--quiet"])
+        else:
+            raise ValueError(f"Unknown sync_method: {self.sync_method}")
+
+    def update_system(self):
+        """Update the system packages."""
+        if self.gentoo_world_update:
+            check_target_env_call(["emerge", "--ask=n", "--verbose=y", "--update", "--deep", "--newuse", "@world"])
+        else:
+            check_target_env_call(["emerge", "--ask=n", "--verbose=y", "--update", "@system"])
+
+    def run(self, script):
+        """Run a custom script."""
+        if script != "":
+            check_target_env_call(script.split(" "))
+
+    def install_package(self, packagedata, from_local=False):
+        """Install a single package with optional pre/post scripts."""
+        if isinstance(packagedata, str):
+            if self.skip_unavailable:
+                try:
+                    self.install([packagedata], from_local=from_local)
+                except subprocess.CalledProcessError:
+                    libcalamares.utils.warning(f"Could not install package {packagedata}")
+            else:
+                self.install([packagedata], from_local=from_local)
+        else:
+            self.run(packagedata.get("pre-script", ""))
+            if self.skip_unavailable:
+                try:
+                    self.install([packagedata["package"]], from_local=from_local)
+                except subprocess.CalledProcessError:
+                    libcalamares.utils.warning(f"Could not install package {packagedata['package']}")
+            else:
+                self.install([packagedata["package"]], from_local=from_local)
+            self.run(packagedata.get("post-script", ""))
+
+    def remove_package(self, packagedata):
+        """Remove a single package with optional pre/post scripts."""
+        if isinstance(packagedata, str):
+            if self.skip_unavailable:
+                try:
+                    self.remove([packagedata])
+                except subprocess.CalledProcessError:
+                    libcalamares.utils.warning(f"Could not remove package {packagedata}")
+            else:
+                self.remove([packagedata])
+        else:
+            self.run(packagedata.get("pre-script", ""))
+            if self.skip_unavailable:
+                try:
+                    self.remove([packagedata["package"]])
+                except subprocess.CalledProcessError:
+                    libcalamares.utils.warning(f"Could not remove package {packagedata['package']}")
+            else:
+                self.remove([packagedata["package"]])
+            self.run(packagedata.get("post-script", ""))
+
+    def operation_install(self, package_list, from_local=False):
+        """Install a list of packages."""
+        if self.skip_unavailable:
+            for package in package_list:
+                self.install_package(package, from_local=from_local)
+        else:
+            if all([isinstance(x, str) for x in package_list]):
+                self.install(package_list, from_local=from_local)
+            else:
+                for package in package_list:
+                    self.install_package(package, from_local=from_local)
+
+    def operation_try_install(self, package_list):
+        """Install packages with error tolerance (like skip_unavailable=true)."""
+        for package in package_list:
+            try:
+                self.install_package(package)
+            except subprocess.CalledProcessError:
+                libcalamares.utils.warning("Could not install package %s" % package)
+
+    def operation_remove(self, package_list):
+        """Remove a list of packages."""
+        if self.skip_unavailable:
+            for package in package_list:
+                self.remove_package(package)
+        else:
+            if all([isinstance(x, str) for x in package_list]):
+                self.remove(package_list)
+            else:
+                for package in package_list:
+                    self.remove_package(package)
+
+    def operation_try_remove(self, package_list):
+        """Remove packages with error tolerance."""
+        for package in package_list:
+            try:
+                self.remove_package(package)
+            except subprocess.CalledProcessError:
+                libcalamares.utils.warning("Could not remove package %s" % package)
+
+
+def subst_locale(plist):
+    """
+    Returns a locale-aware list of packages, based on @p plist.
+    Package names that contain LOCALE are localized with the
+    BCP47 name of the chosen system locale; if the system
+    locale is 'en' (e.g. English, US) then these localized
+    packages are dropped from the list.
+    """
+    locale = libcalamares.globalstorage.value("locale")
+    if not locale:
+        locale = "en"
+
+    ret = []
+    for packagedata in plist:
+        if isinstance(packagedata, str):
+            packagename = packagedata
+        else:
+            packagename = packagedata["package"]
+
+        if locale != "en":
+            packagename = Template(packagename).safe_substitute(LOCALE=locale)
+        elif 'LOCALE' in packagename:
+            packagename = None
+
+        if packagename is not None:
+            if isinstance(packagedata, str):
+                packagedata = packagename
+            else:
+                packagedata["package"] = packagename
+
+            ret.append(packagedata)
+
+    return ret
+
+
+def run_operations(pkgman, entry):
+    """
+    Call package manager with suitable parameters for the given package actions.
+    """
+    global group_packages, completed_packages, mode_packages
+
+    for key in entry.keys():
+        package_list = subst_locale(entry[key])
+        group_packages = len(package_list)
+        if key == "install":
+            _change_mode(INSTALL)
+            pkgman.operation_install(package_list)
+        elif key == "try_install":
+            _change_mode(INSTALL)
+            pkgman.operation_try_install(package_list)
+        elif key == "remove":
+            _change_mode(REMOVE)
+            pkgman.operation_remove(package_list)
+        elif key == "try_remove":
+            _change_mode(REMOVE)
+            pkgman.operation_try_remove(package_list)
+        elif key == "localInstall":
+            _change_mode(INSTALL)
+            pkgman.operation_install(package_list, from_local=True)
+        elif key == "source":
+            libcalamares.utils.debug("Package-list from {!s}".format(entry[key]))
+        else:
+            libcalamares.utils.warning("Unknown package-operation key {!s}".format(key))
+        completed_packages += len(package_list)
+        libcalamares.job.setprogress(completed_packages * 1.0 / total_packages)
+        libcalamares.utils.debug("Pretty name: {!s}, setting progress..".format(pretty_name()))
+
+    group_packages = 0
+    _change_mode(None)
+
+
+def run():
+    """
+    Main entry point for the gentoopkg module.
+    Installs/removes packages using Gentoo's Portage system with additional
+    Gentoo-specific features.
+    """
+    global mode_packages, total_packages, completed_packages, group_packages
+
+    pkgman = GentooPackageManager()
+
+    skip_this = libcalamares.job.configuration.get("skip_if_no_internet", False)
+    if skip_this and not libcalamares.globalstorage.value("hasInternet"):
+        libcalamares.utils.warning("Package installation has been skipped: no internet")
+        return None
+
+    update_db = libcalamares.job.configuration.get("update_db", False)
+    if update_db and libcalamares.globalstorage.value("hasInternet"):
+        libcalamares.utils.debug("Starting Portage tree sync...")
+        try:
+            pkgman.update_db()
+            libcalamares.utils.debug("Portage tree sync completed successfully")
+        except subprocess.CalledProcessError as e:
+            libcalamares.utils.warning(str(e))
+            libcalamares.utils.debug("stdout:" + str(e.stdout))
+            libcalamares.utils.debug("stderr:" + str(e.stderr))
+            # If skip_unavailable is enabled, don't fail completely on sync errors
+            if not pkgman.skip_unavailable:
+                return (_("Package Manager error"),
+                        _("The package manager could not prepare updates. The command <pre>{!s}</pre> returned error code {!s}.")
+                        .format(e.cmd, e.returncode))
+            else:
+                libcalamares.utils.warning("Portage sync failed but continuing due to skip_unavailable setting")
+
+    update_system = libcalamares.job.configuration.get("update_system", False)
+    if update_system and libcalamares.globalstorage.value("hasInternet"):
+        try:
+            pkgman.update_system()
+        except subprocess.CalledProcessError as e:
+            libcalamares.utils.warning(str(e))
+            libcalamares.utils.debug("stdout:" + str(e.stdout))
+            libcalamares.utils.debug("stderr:" + str(e.stderr))
+            return (_("Package Manager error"),
+                    _("The package manager could not update the system. The command <pre>{!s}</pre> returned error code {!s}.")
+                    .format(e.cmd, e.returncode))
+
+    operations = libcalamares.job.configuration.get("operations", [])
+    if libcalamares.globalstorage.contains("packageOperations"):
+        operations += libcalamares.globalstorage.value("packageOperations")
+
+    mode_packages = None
+    total_packages = 0
+    completed_packages = 0
+    for op in operations:
+        for packagelist in op.values():
+            total_packages += len(subst_locale(packagelist))
+
+    if not total_packages:
+        return None
+
+    for entry in operations:
+        group_packages = 0
+        libcalamares.utils.debug(pretty_name())
+        try:
+            run_operations(pkgman, entry)
+        except subprocess.CalledProcessError as e:
+            if not pkgman.skip_unavailable:
+                libcalamares.utils.warning(str(e))
+                libcalamares.utils.debug("stdout:" + str(e.stdout))
+                libcalamares.utils.debug("stderr:" + str(e.stderr))
+                return (_("Package Manager error"),
+                        _("The package manager could not make changes to the installed system. The command <pre>{!s}</pre> returned error code {!s}.")
+                        .format(e.cmd, e.returncode))
+            else:
+                # Just log the error and continue
+                libcalamares.utils.warning(f"Package operation failed but continuing due to skip_unavailable: {e}")
+
+    mode_packages = None
+    libcalamares.job.setprogress(1.0)
+
+    return None
diff --git a/src/modules/gentoopkg/module.desc b/src/modules/gentoopkg/module.desc
new file mode 100644
index 0000000000..57caf17c79
--- /dev/null
+++ b/src/modules/gentoopkg/module.desc
@@ -0,0 +1,9 @@
+# SPDX-FileCopyrightText: no
+# SPDX-License-Identifier: CC0-1.0
+# Module metadata file for gentoopkg jobmodule
+# Syntax is YAML 1.2
+---
+type:       "job"
+name:       "gentoopkg"
+interface:  "python"
+script:     "main.py"
diff --git a/src/modules/gentoopkg/test-skip-unavailable.conf b/src/modules/gentoopkg/test-skip-unavailable.conf
new file mode 100644
index 0000000000..a58c1eb41b
--- /dev/null
+++ b/src/modules/gentoopkg/test-skip-unavailable.conf
@@ -0,0 +1,32 @@
+# Test configuration for gentoopkg module
+# This configuration demonstrates the skip_unavailable feature
+---
+
+skip_unavailable: true
+
+skip_if_no_internet: false
+update_db: false
+update_system: false
+gentoo_world_update: false
+
+accept_keywords:
+  - "app-editors/vim"
+
+operations:
+  - install:
+    - app-editors/vim
+    - sys-process/htop
+    - totally-fake-package-that-does-not-exist
+    - another-nonexistent-package
+  
+  - try_install:
+    - fake-package-for-testing
+    - non-existent-category/fake-package
+  
+  - remove:
+    - games-misc/fortune-mod
+    - fake-package-to-remove
+  
+  - try_install:
+    - dev-lang/python[sqlite]
+    - =sys-kernel/gentoo-sources-6.1*
diff --git a/src/modules/stagechoose/CMakeLists.txt b/src/modules/stagechoose/CMakeLists.txt
new file mode 100644
index 0000000000..f1ee399f9e
--- /dev/null
+++ b/src/modules/stagechoose/CMakeLists.txt
@@ -0,0 +1,13 @@
+calamares_add_plugin(stagechoose
+    TYPE viewmodule
+    EXPORT_MACRO PLUGINDLLEXPORT_PRO
+    SOURCES
+        Config.cpp
+        StageChooseViewStep.cpp
+        StageChoosePage.cpp
+        SetStage3Job.cpp
+        StageFetcher.cpp
+    UI
+        StageChoosePage.ui
+    SHARED_LIB
+)
diff --git a/src/modules/stagechoose/Config.cpp b/src/modules/stagechoose/Config.cpp
new file mode 100644
index 0000000000..ac5feb3960
--- /dev/null
+++ b/src/modules/stagechoose/Config.cpp
@@ -0,0 +1,128 @@
+/* === This file is part of Calamares - <https://calamares.io> ===
+ *
+ *   SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot <groot@kde.org>
+ *   SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ *   Calamares is Free Software: see the License-Identifier above.
+ *
+ */
+
+#include "Config.h"
+#include "locale/Global.h"
+#include "JobQueue.h"
+#include "GlobalStorage.h"
+#include "StageFetcher.h"
+
+#include <QDateTime>
+
+Config::Config(QObject* parent)
+    : QObject(parent)
+    , m_fetcher(new StageFetcher(this))
+{
+    connect(m_fetcher, &StageFetcher::variantsFetched, this, [this](const QStringList &variants) {
+        emit variantsReady(variants);
+    });
+
+    connect(m_fetcher, &StageFetcher::tarballFetched, this, [this](const QString &tarball) {
+        updateTarball(tarball);
+    });
+
+    connect(m_fetcher, &StageFetcher::fetchStatusChanged,this,&Config::fetchStatusChanged);
+    connect(m_fetcher, &StageFetcher::fetchError,this,&Config::fetchError);
+    /// change Config into function handles the fetcher signals
+    m_fetcher->setMirrorBase(m_mirrorBase);
+}
+
+QList<ArchitectureInfo> Config::availableArchitecturesInfo()
+{
+    QList<ArchitectureInfo> list;
+    list << ArchitectureInfo{ QStringLiteral("alpha"),   QStringLiteral("Digital Alpha (alpha)") }
+         << ArchitectureInfo{ QStringLiteral("amd64"),   QStringLiteral("64-bit Intel/AMD (amd64)") }
+         << ArchitectureInfo{ QStringLiteral("x86"),     QStringLiteral("32-bit Intel/AMD (x86)") }
+         << ArchitectureInfo{ QStringLiteral("arm"),     QStringLiteral("ARM 32-bit (arm)") }
+         << ArchitectureInfo{ QStringLiteral("arm64"),   QStringLiteral("ARM 64-bit (arm64)") }
+         << ArchitectureInfo{ QStringLiteral("hppa"),    QStringLiteral("HPPA (hppa)") }
+         << ArchitectureInfo{ QStringLiteral("ia64"),    QStringLiteral("Intel Itanium (ia64)") }
+         << ArchitectureInfo{ QStringLiteral("loong"),   QStringLiteral("Loongson MIPS-based (loong)") }
+         << ArchitectureInfo{ QStringLiteral("m68k"),    QStringLiteral("Motorola 68k (m68k)") }
+         << ArchitectureInfo{ QStringLiteral("mips"),    QStringLiteral("MIPS 32/64-bit (mips)") }
+         << ArchitectureInfo{ QStringLiteral("ppc"),     QStringLiteral("PowerPC (ppc)") }
+         << ArchitectureInfo{ QStringLiteral("riscv"),   QStringLiteral("RISC-V 32/64-bit (riscv)") }
+         << ArchitectureInfo{ QStringLiteral("s390"),    QStringLiteral("IBM System z (s390)") }
+         << ArchitectureInfo{ QStringLiteral("sh"),      QStringLiteral("SuperH legacy (sh)") }
+         << ArchitectureInfo{ QStringLiteral("sparc"),   QStringLiteral("SPARC 64-bit (sparc)") }
+         << ArchitectureInfo{ QStringLiteral("livecd"), QStringLiteral("Live CD (unsafe)") };
+    return list;
+}
+
+void Config::availableStagesFor(const QString& arch)
+{
+    m_selectedArch = arch;
+    m_selectedVariant.clear();
+    if(arch == "livecd"){
+        m_fetcher->cancelOngoingRequest();
+        m_selectedTarball = "livecd";
+        emit tarballReady(m_selectedTarball);
+        emit fetchStatusChanged("LiveCD mode");
+        emit validityChanged(isValid());
+        return;
+    }
+    else{
+        m_selectedTarball.clear();
+        m_fetcher->fetchVariants(arch);
+    }
+}
+
+void Config::selectVariant(const QString& variant)
+{
+    m_selectedVariant = variant;
+
+    m_fetcher->fetchLatestTarball(m_selectedArch,variant);
+}
+
+QString Config::selectedStage3() const
+{
+    if(!m_selectedTarball.isEmpty())
+        return m_selectedTarball;
+
+    return "No tar fetched";
+}
+
+bool Config::isValid() const
+{
+    return (!m_selectedTarball.isEmpty()) ;
+}
+
+void Config::setMirrorBase(const QString& mirror){
+    QString base = mirror.trimmed();
+    while(base.endsWith('/')) base.chop(1);
+
+    if(base.isEmpty()) base = QStringLiteral("http://distfiles.gentoo.org/releases");
+
+    if(base == m_mirrorBase) return;
+
+    m_mirrorBase = base;
+    if(m_fetcher) m_fetcher->setMirrorBase(m_mirrorBase);
+}
+
+void Config::updateTarball(const QString &tarball){
+    m_selectedTarball = tarball;
+    emit tarballReady(tarball);
+    emit validityChanged(isValid());
+}
+
+void Config::updateGlobalStorage()
+{
+    Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage();
+
+    if(m_selectedArch == "livecd")
+        gs->insert("GENTOO_LIVECD","yes");
+    else{
+        gs->insert("GENTOO_LIVECD","no");
+        gs->insert( "BASE_DOWNLOAD_URL",  QString("%1/%2/autobuilds/%3/").arg(m_mirrorBase,m_selectedArch,m_selectedVariant));
+        gs->insert( "FINAL_DOWNLOAD_URL",  QString("%1/%2/autobuilds/%3/%4").arg(m_mirrorBase,m_selectedArch,m_selectedVariant,m_selectedTarball));
+        gs->insert( "STAGE_NAME_TAR", m_selectedTarball );
+    }
+}
+
+
diff --git a/src/modules/stagechoose/Config.h b/src/modules/stagechoose/Config.h
new file mode 100644
index 0000000000..6ba116ed7e
--- /dev/null
+++ b/src/modules/stagechoose/Config.h
@@ -0,0 +1,65 @@
+/* === This file is part of Calamares - <https://calamares.io> ===
+ *
+ *   SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot <groot@kde.org>
+ *   SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ *   Calamares is Free Software: see the License-Identifier above.
+ *
+ */
+
+#ifndef CONFIG_H
+#define CONFIG_H
+
+#include "StageFetcher.h"
+#include <QObject>
+#include <QPair>
+#include <QString>
+#include <QList>
+
+struct ArchitectureInfo
+{
+    QString name;
+    QString description;
+
+    ArchitectureInfo() = default;
+    ArchitectureInfo(const QString& n, const QString& d):
+    name(n),description(d){}
+};
+
+class Config : public QObject
+{
+    Q_OBJECT
+
+public:
+    explicit Config(QObject* parent = nullptr);
+
+    QList<ArchitectureInfo> availableArchitecturesInfo();
+    QStringList availableArchitectures();
+    void availableStagesFor(const QString& architecture);
+    void selectVariant(const QString& variantKey);
+
+    QString selectedStage3() const;
+    bool isValid() const;
+
+    void updateGlobalStorage();
+    void updateTarball(const QString &tarball);
+    void setMirrorBase(const QString& mirror);
+    QString mirrorBase();
+
+signals:
+    void variantsReady(const QStringList& variants);
+    void tarballReady(const QString& tarball);
+    void fetchStatusChanged(const QString& status);
+    void fetchError(const QString& error);
+    void validityChanged(bool validity);
+
+private:
+    StageFetcher* m_fetcher;
+    QString m_mirrorBase {QStringLiteral("http://distfiles.gentoo.org/releases")};
+    QString m_selectedArch;
+    QString m_selectedVariant;
+    QString m_selectedTarball;
+};
+
+#endif // CONFIG_H
+
diff --git a/src/modules/stagechoose/SetStage3Job.cpp b/src/modules/stagechoose/SetStage3Job.cpp
new file mode 100644
index 0000000000..086085862a
--- /dev/null
+++ b/src/modules/stagechoose/SetStage3Job.cpp
@@ -0,0 +1,61 @@
+#include "SetStage3Job.h"
+
+#include "utils/Logger.h"
+#include <QFile>
+#include <QTextStream>
+#include <QRegularExpression>
+
+SetStage3Job::SetStage3Job(const QString& tarballName)
+    : m_tarballName(tarballName)
+{
+}
+
+QString SetStage3Job::prettyName() const
+{
+    return QString("Write selected Gentoo Stage3 to config: %1").arg(m_tarballName);
+}
+
+Calamares::JobResult SetStage3Job::exec()
+{
+    if(m_tarballName.isEmpty()){
+        return Calamares::JobResult::error(
+            "No stage3 tarball selected.","Stage3 tarball name is empty."
+        );
+    }
+
+    QString configPath = "/etc/calamares.conf";
+    QFile file(configPath);
+    QString contents;
+
+    if (file.exists()) {
+        if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
+            return Calamares::JobResult::error(
+                "Failed to open Calamares config file for reading.",
+                configPath);
+        }
+        QTextStream in(&file);
+        contents = in.readAll();
+        file.close();
+    }
+
+    QString stage3Line = QString("stage3 = %1").arg(m_tarballName);
+
+    if (contents.contains(QRegularExpression(R"(stage3\s*=)"))) {
+        contents.replace(QRegularExpression(R"(stage3\s*=.*)"), stage3Line);
+    } else {
+        contents.append("\n" + stage3Line + "\n");
+    }
+
+    if (!file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) {
+        return Calamares::JobResult::error(
+            "Failed to open Calamares config file for writing.",
+            configPath);
+    }
+
+    QTextStream out(&file);
+    out << contents;
+    file.close();
+
+    cDebug() << "Wrote stage3 tarball to config:" << m_tarballName;
+    return Calamares::JobResult::ok();
+}
diff --git a/src/modules/stagechoose/SetStage3Job.h b/src/modules/stagechoose/SetStage3Job.h
new file mode 100644
index 0000000000..edea12ce2c
--- /dev/null
+++ b/src/modules/stagechoose/SetStage3Job.h
@@ -0,0 +1,22 @@
+#ifndef SETSTAGE3JOB_H
+#define SETSTAGE3JOB_H
+
+#include <Job.h>
+#include <QString>
+
+/**
+ * @brief A job to write the selected Stage3 tarball name to /etc/calamares.conf
+ */
+class SetStage3Job : public Calamares::Job
+{
+public:
+    explicit SetStage3Job(const QString& tarballName);
+
+    QString prettyName() const override;
+    Calamares::JobResult exec() override;
+
+private:
+    QString m_tarballName;
+};
+
+#endif // SETSTAGE3JOB_H
diff --git a/src/modules/stagechoose/StageChoosePage.cpp b/src/modules/stagechoose/StageChoosePage.cpp
new file mode 100644
index 0000000000..5bf3eacb5e
--- /dev/null
+++ b/src/modules/stagechoose/StageChoosePage.cpp
@@ -0,0 +1,145 @@
+/* === This file is part of Calamares - <https://calamares.io> ===
+ *
+ *   SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac <teo@kde.org>
+ *   SPDX-FileCopyrightText: 2015 Anke Boersma <demm@kaosx.us>
+ *   SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot <groot@kde.org>
+ *   SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ *   Calamares is Free Software: see the License-Identifier above.
+ *
+ */
+#include "StageChoosePage.h"
+#include "Config.h"
+#include "ui_StageChoosePage.h"
+
+#include <QComboBox>
+#include <QLabel>
+#include <QTimer>
+#include <QPushButton>
+
+StageChoosePage::StageChoosePage(Config* config, QWidget* parent)
+    : QWidget(parent)
+    , ui(new Ui::StageChoosePage)
+    , m_config(config)
+{
+    ui->setupUi(this);
+
+    connect(ui->architectureComboBox, QOverload<int>::of(&QComboBox::activated),
+            this, &StageChoosePage::onArchitectureChanged);
+    connect(ui->variantComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged),
+            this, &StageChoosePage::onVariantChanged);
+
+    connect(ui->mirrorLineEdit, &QLineEdit::editingFinished, this, &StageChoosePage::onMirrorChanged);
+    connect(ui->restartFetcherButton, &QPushButton::clicked, this, &StageChoosePage::onRestartFetcherClicked);
+
+    if(m_config){
+        connect(m_config, &Config::fetchStatusChanged,this,&StageChoosePage::setFetcherStatus);
+        connect(m_config, &Config::fetchError,this,[this](const QString& error){setFetcherStatus("Error" + error);showRestartFetcherButton(true);});
+        connect(m_config, &Config::variantsReady, this, &StageChoosePage::whenVariantsReady);
+        connect(m_config, &Config::tarballReady, this, [this](const QString&){updateSelectedTarballLabel();});
+    }
+
+    setFetcherStatus("Idle");
+    updateSelectedTarballLabel();
+    showRestartFetcherButton(false);
+
+    populateArchs();
+}
+
+void StageChoosePage::onMirrorChanged()
+{
+    if(!m_config) return;
+    QString mirror = ui->mirrorLineEdit->text().trimmed();
+    m_config->setMirrorBase(mirror);
+}
+
+void StageChoosePage::setFetcherStatus(const QString& status)
+{
+    ui->fetcherStatusLabel->setText("Status: " + status);
+}
+
+void StageChoosePage::showRestartFetcherButton(bool visible)
+{
+    ui->restartFetcherButton->setVisible(false);
+    // To implement
+}
+
+void StageChoosePage::onRestartFetcherClicked(){
+    // Logic here
+    setFetcherStatus("Restarting...");
+    showRestartFetcherButton(false);
+}
+
+void StageChoosePage::populateArchs()
+{
+    if (!m_config)
+        return;
+
+    const auto archs = m_config->availableArchitecturesInfo();
+    ui->architectureComboBox->clear();
+    for(const auto& arch : archs){
+        ui->architectureComboBox->addItem(arch.description,arch.name);
+    }
+    ui->architectureComboBox->setCurrentIndex(-1);
+}
+
+void StageChoosePage::onArchitectureChanged(int index)
+{
+    if (!m_config)
+        return;
+
+    const QString archKey = ui->architectureComboBox->itemData(index).toString();
+    ui->variantComboBox->clear();
+
+    m_config->availableStagesFor(archKey);
+
+    if(archKey == "livecd"){
+        ui->variantComboBox->setVisible(false);
+        ui->variantLabel->setVisible(false);
+
+        // setFetcherStatus("LiveCD mode");
+        // m_config->updateTarball("livecd");
+        showRestartFetcherButton(false);
+        return;
+    }
+    else{
+        ui->variantComboBox->setVisible(true);
+        ui->variantLabel->setVisible(true);
+    }
+}
+
+void StageChoosePage::onVariantChanged(int index)
+{
+    if (!m_config)
+        return;
+
+    const QString variantKey = ui->variantComboBox->itemData(index).toString();
+    m_config->selectVariant(variantKey);
+}
+
+void StageChoosePage::whenVariantsReady(const QStringList &stages)
+{
+    ui->variantComboBox->clear();
+
+    for(const QString& stage : stages){
+        ui->variantComboBox->addItem(stage, stage);
+    }
+
+    if(!stages.isEmpty()){
+        ui->variantComboBox->setCurrentIndex(0);
+        onVariantChanged(0);
+    }
+}
+
+void StageChoosePage::updateSelectedTarballLabel()
+{
+    if (!m_config)
+        return;
+
+    ui->selectedTarballLabel->setText("Selected: " + m_config->selectedStage3());
+}
+
+StageChoosePage::~StageChoosePage()
+{
+    delete ui;
+}
diff --git a/src/modules/stagechoose/StageChoosePage.h b/src/modules/stagechoose/StageChoosePage.h
new file mode 100644
index 0000000000..65e6633184
--- /dev/null
+++ b/src/modules/stagechoose/StageChoosePage.h
@@ -0,0 +1,51 @@
+/* === This file is part of Calamares - <https://calamares.io> ===
+ *
+ *   SPDX-FileCopyrightText: 2014 Teo Mrnjavac <teo@kde.org>
+ *   SPDX-FileCopyrightText: 2019 Adriaan de Groot <groot@kde.org>
+ *   SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ *   Calamares is Free Software: see the License-Identifier above.
+ *
+ */
+
+#ifndef STAGECHOOSEPAGE_H
+#define STAGECHOOSEPAGE_H
+
+#include <QWidget>
+
+class QComboBox;
+class QLabel;
+class Config;
+
+namespace Ui {
+class StageChoosePage;
+}
+
+class StageChoosePage : public QWidget
+{
+    Q_OBJECT
+
+public:
+    explicit StageChoosePage( Config* config, QWidget* parent = nullptr);
+    ~StageChoosePage() override;
+
+    void populateArchs();
+    void setFetcherStatus(const QString& status);
+    void showRestartFetcherButton(bool visible);
+    void onRestartFetcherClicked();
+    void whenVariantsReady(const QStringList &stages);
+
+    void onMirrorChanged();
+
+private slots:
+    void onArchitectureChanged(int index);
+    void onVariantChanged(int index);
+    void updateSelectedTarballLabel();
+
+private:
+    Ui::StageChoosePage* ui;
+    Config* m_config;
+};
+
+#endif // STAGECHOOSEPAGE_H
+
diff --git a/src/modules/stagechoose/StageChoosePage.ui b/src/modules/stagechoose/StageChoosePage.ui
new file mode 100644
index 0000000000..f78482abd0
--- /dev/null
+++ b/src/modules/stagechoose/StageChoosePage.ui
@@ -0,0 +1,172 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>StageChoosePage</class>
+ <widget class="QWidget" name="StageChoosePage">
+  <layout class="QVBoxLayout" name="outerVerticalLayout">
+
+   <item>
+    <spacer name="verticalSpacerTop">
+     <property name="orientation">
+      <enum>Qt::Vertical</enum>
+     </property>
+     <property name="sizeType">
+      <enum>QSizePolicy::Expanding</enum>
+     </property>
+     <property name="sizeHint" stdset="0">
+      <size>
+       <width>20</width>
+       <height>40</height>
+      </size>
+     </property>
+    </spacer>
+   </item>
+
+   <item>
+    <layout class="QHBoxLayout" name="horizontalCenteringLayout">
+     <item>
+      <spacer name="horizontalSpacerLeft">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="sizeType">
+        <enum>QSizePolicy::Expanding</enum>
+       </property>
+      </spacer>
+     </item>
+
+     <item>
+      <layout class="QVBoxLayout" name="verticalLayout">
+        <item>
+         <widget class="QLabel" name="mirrorInfoLabel">
+          <property name="text">
+            <string>If you leave mirror link blank, it will choose the default option.</string>
+          </property>
+            <property name="wordWrap">
+            <bool>true</bool>
+          </property>
+         </widget>
+        </item>
+        
+       <item>
+        <layout class="QHBoxLayout" name="mirrorLayout">
+         <item>
+          <widget class="QLabel" name="mirrorLabel">
+           <property name="text">
+            <string>Mirror Link:</string>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QLineEdit" name="mirrorLineEdit">
+           <property name="placeholderText">
+            <string>https://distfiles.gentoo.org/releases/</string>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+
+       <item>
+        <layout class="QHBoxLayout" name="topLabelsLayout">
+         <item>
+          <widget class="QLabel" name="archLabel">
+           <property name="text">
+            <string>Select Architecture:</string>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QLabel" name="variantLabel">
+           <property name="text">
+            <string>Select Stage3 Option:</string>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+
+       <item>
+        <layout class="QHBoxLayout" name="comboBoxesLayout">
+         <item>
+          <widget class="QComboBox" name="architectureComboBox">
+           <property name="minimumSize">
+            <size><width>200</width><height>25</height></size>
+           </property>
+           <property name="maximumSize">
+            <size><width>200</width><height>25</height></size>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QComboBox" name="variantComboBox">
+           <property name="minimumSize">
+            <size><width>200</width><height>25</height></size>
+           </property>
+           <property name="maximumSize">
+            <size><width>200</width><height>25</height></size>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+
+       <item>
+        <widget class="QLabel" name="selectedTarballLabel">
+         <property name="text">
+          <string>Selected: </string>
+         </property>
+        </widget>
+       </item>
+
+       <item>
+        <widget class="QLabel" name="fetcherStatusLabel">
+         <property name="text">
+          <string>Status: Idle</string>
+         </property>
+         <property name="alignment">
+          <set>Qt::AlignCenter</set>
+         </property>
+        </widget>
+       </item>
+
+       <item>
+        <widget class="QPushButton" name="restartFetcherButton">
+         <property name="text">
+          <string>Restart Fetcher</string>
+         </property>
+         <property name="visible">
+          <bool>false</bool>
+         </property>
+        </widget>
+       </item>
+      </layout>
+     </item>
+
+     <item>
+      <spacer name="horizontalSpacerRight">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="sizeType">
+        <enum>QSizePolicy::Expanding</enum>
+       </property>
+      </spacer>
+     </item>
+    </layout>
+   </item>
+
+   <item>
+    <spacer name="verticalSpacerBottom">
+     <property name="orientation">
+      <enum>Qt::Vertical</enum>
+     </property>
+     <property name="sizeType">
+      <enum>QSizePolicy::Expanding</enum>
+     </property>
+    </spacer>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/src/modules/stagechoose/StageChooseViewStep.cpp b/src/modules/stagechoose/StageChooseViewStep.cpp
new file mode 100644
index 0000000000..24857f07f0
--- /dev/null
+++ b/src/modules/stagechoose/StageChooseViewStep.cpp
@@ -0,0 +1,80 @@
+/* === This file is part of Calamares - <https://calamares.io> ===
+ *
+ *   SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac <teo@kde.org>
+ *   SPDX-FileCopyrightText: 2018 Adriaan de Groot <groot@kde.org>
+ *   SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ *   Calamares is Free Software: see the License-Identifier above.
+ *
+ */
+
+#include "StageChooseViewStep.h"
+
+#include "Config.h"
+#include "StageChoosePage.h"
+#include "SetStage3Job.h"
+
+#include "utils/Logger.h"
+
+CALAMARES_PLUGIN_FACTORY_DEFINITION(StageChooseViewStepFactory, registerPlugin<StageChooseViewStep>();)
+
+StageChooseViewStep::StageChooseViewStep(QObject* parent)
+    : Calamares::ViewStep(parent)
+    , m_config(new Config(this))
+    , m_widget(new StageChoosePage(m_config))
+{
+   connect(m_config,&Config::validityChanged,this,[this](bool valid){emit nextStatusChanged(valid);});
+}
+
+StageChooseViewStep::~StageChooseViewStep() 
+{
+    if ( m_widget && m_widget->parent() == nullptr )
+    {
+        m_widget->deleteLater();
+    }
+}
+
+QString StageChooseViewStep::prettyName() const
+{
+    return tr("Select Stage");
+}
+
+QWidget* StageChooseViewStep::widget()
+{
+    return m_widget;
+}
+
+bool StageChooseViewStep::isNextEnabled() const
+{
+    return m_config->isValid();
+}
+
+bool StageChooseViewStep::isBackEnabled() const
+{
+    return true;
+}
+
+bool StageChooseViewStep::isAtBeginning() const
+{
+    return true;
+}
+
+bool StageChooseViewStep::isAtEnd() const
+{
+    return true;
+}
+
+void StageChooseViewStep::onLeave()
+{
+    m_config->updateGlobalStorage();
+}
+
+Calamares::JobList StageChooseViewStep::jobs() const
+{
+    Calamares::JobList list;
+    if (m_config->isValid())
+    {
+        list.append(QSharedPointer<SetStage3Job>::create(m_config->selectedStage3()));
+    }
+    return list;
+}
diff --git a/src/modules/stagechoose/StageChooseViewStep.h b/src/modules/stagechoose/StageChooseViewStep.h
new file mode 100644
index 0000000000..d6efed30f4
--- /dev/null
+++ b/src/modules/stagechoose/StageChooseViewStep.h
@@ -0,0 +1,53 @@
+/* === This file is part of Calamares - <https://calamares.io> ===
+ *
+ *   SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac <teo@kde.org>
+ *   SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ *   Calamares is Free Software: see the License-Identifier above.
+ *
+ */
+
+#ifndef STAGECHOOSEVIEWSTEP_H
+#define STAGECHOOSEVIEWSTEP_H
+
+#include <QObject>
+#include <QWidget>
+#include <QString>
+
+#include "DllMacro.h"
+#include "utils/PluginFactory.h"
+#include "viewpages/ViewStep.h"
+
+class StageChoosePage;
+class Config;
+
+class PLUGINDLLEXPORT StageChooseViewStep : public Calamares::ViewStep
+{
+    Q_OBJECT
+
+public:
+    explicit StageChooseViewStep(QObject* parent = nullptr);
+    ~StageChooseViewStep() override;
+
+    QString prettyName() const override;
+
+    QWidget* widget() override;
+
+    bool isNextEnabled() const override;
+    bool isBackEnabled() const override;
+    bool isAtBeginning() const override;
+    bool isAtEnd() const override;
+
+    Calamares::JobList jobs() const override;
+
+    void onLeave() override;
+
+private:
+    Config* m_config;
+    StageChoosePage* m_widget;
+};
+
+CALAMARES_PLUGIN_FACTORY_DECLARATION( StageChooseViewStepFactory )
+
+#endif // STAGECHOOSEVIEWSTEP_H
+
diff --git a/src/modules/stagechoose/StageFetcher.cpp b/src/modules/stagechoose/StageFetcher.cpp
new file mode 100644
index 0000000000..287ab59023
--- /dev/null
+++ b/src/modules/stagechoose/StageFetcher.cpp
@@ -0,0 +1,152 @@
+#include "StageFetcher.h"
+
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+#include <QEventLoop>
+#include <QRegularExpression>
+#include <QRegularExpressionMatch>
+#include <QRegularExpressionMatchIterator>
+#include <QStringList>
+
+StageFetcher :: StageFetcher(QObject* parent):QObject(parent)
+{
+}
+
+QString StageFetcher::extractvariantBase(const QString& variant){
+    if(variant.startsWith("current-"))
+        return variant.mid(8);
+    return variant;
+}
+
+void StageFetcher::setMirrorBase(const QString& mirror)
+{
+    QString base = mirror.trimmed();
+    while(base.endsWith('/')) base.chop(1);
+
+    if(base.isEmpty())
+        base = QStringLiteral("http://distfiles.gentoo.org/releases");
+
+    if(!base.endsWith("/releases"))
+        base += "/releases";
+
+    m_mirrorBase = base;
+}
+
+void StageFetcher::cancelOngoingRequest()
+{
+    if(m_currentReply){
+        disconnect(m_currentReply,nullptr,this,nullptr);
+        if(m_currentReply->isRunning())
+            m_currentReply->abort();
+        m_currentReply->deleteLater();
+        m_currentReply = nullptr;
+    }
+}
+
+void StageFetcher::fetchVariants(const QString& arch)
+{
+    cancelOngoingRequest(); 
+    emit fetchStatusChanged("Fetching variants for " + arch + "...");
+
+    QString urlStr = QString("%1/%2/autobuilds/").arg(m_mirrorBase, arch);
+    QUrl url(urlStr);
+    QNetworkRequest request(url);
+
+    QNetworkReply* reply = m_nam.get(request);
+    m_currentReply = reply;
+    connect(reply, &QNetworkReply::finished, this,[this, reply](){onVariantsReplyFinished(reply);});
+}
+
+void StageFetcher::onVariantsReplyFinished(QNetworkReply* reply)
+{
+    if(!reply)
+        return;
+
+    if(reply != m_currentReply){
+        reply->deleteLater();
+        return;
+    }
+
+    QStringList variants;
+    if(reply->error() != QNetworkReply::NoError){
+        emit fetchError(reply->errorString());
+        reply->deleteLater();
+        if(m_currentReply == reply) m_currentReply = nullptr;
+        return;
+    }
+
+    QString html = reply->readAll();
+     if(html.isEmpty())
+        emit variantsFetched(variants);
+
+    QRegularExpression re(R"((current-stage3-[^"/]+)[/])");
+    QRegularExpressionMatchIterator iterator = re.globalMatch(html);
+
+    QStringList seen;
+    while(iterator.hasNext()){
+        QRegularExpressionMatch match = iterator.next();
+        QString variant = match.captured(1);
+        if(!seen.contains(variant)){
+            variants.append(variant);
+            seen.append(variant);
+        }
+    }
+
+    emit variantsFetched(variants);
+    emit fetchStatusChanged("Idle");
+    reply->deleteLater();
+    if(reply == m_currentReply) m_currentReply = nullptr;
+}
+
+void StageFetcher::fetchLatestTarball(const QString& arch, const QString& variant)
+{
+    cancelOngoingRequest();
+    emit fetchStatusChanged("Fetching Tarball for "+ variant +"...");
+    const QString baseUrl = QString("%1/%2/autobuilds/%3/").arg(m_mirrorBase, arch, variant);
+    QUrl url(baseUrl);
+    QNetworkRequest request(url);
+
+    QNetworkReply* reply = m_nam.get(request);
+    m_currentReply = reply;
+    connect(reply, &QNetworkReply::finished, this, [this, reply, variant](){onTarballReplyFinished(reply, variant);});
+}
+
+void StageFetcher::onTarballReplyFinished(QNetworkReply* reply, const QString& variant)
+{
+    if(!reply)
+        return;
+
+    if(reply != m_currentReply){
+        reply->deleteLater();
+        return;
+    }
+
+    QString latest;
+    if(reply->error() != QNetworkReply::NoError){
+        emit fetchError(reply->errorString());
+        reply->deleteLater();
+        if(m_currentReply == reply) m_currentReply = nullptr;
+        return;
+    }
+
+    QString html = reply->readAll();
+    if(html.isEmpty())
+        emit tarballFetched(latest);
+
+    QRegularExpression re(QString("(%1-[\\dTZ]+\\.tar\\.xz)").arg(StageFetcher::extractvariantBase(variant)));
+    QRegularExpressionMatchIterator iterator = re.globalMatch(html);
+
+    while(iterator.hasNext()){
+        QRegularExpressionMatch match = iterator.next();
+        QString filename = match.captured(1);
+        if(filename > latest){
+            latest = filename;
+        }
+    }
+
+    emit tarballFetched(latest);
+    emit fetchStatusChanged("Idle");
+    reply->deleteLater();
+    if(reply == m_currentReply) m_currentReply = nullptr;
+}
\ No newline at end of file
diff --git a/src/modules/stagechoose/StageFetcher.h b/src/modules/stagechoose/StageFetcher.h
new file mode 100644
index 0000000000..8c97f29b55
--- /dev/null
+++ b/src/modules/stagechoose/StageFetcher.h
@@ -0,0 +1,42 @@
+#ifndef STAGEFETCHER_H
+#define STAGEFETCHER_H
+
+#include <QNetworkAccessManager>
+#include <QObject>
+#include <QNetworkReply>
+#include <QPointer>
+#include <QString>
+#include <QStringList>
+#include <QUrl>
+
+class StageFetcher : public QObject
+{
+    Q_OBJECT
+
+public: 
+    explicit StageFetcher(QObject* parent =nullptr);
+
+    void fetchVariants(const QString& arch);
+    QString extractvariantBase(const QString& varaint);
+    void fetchLatestTarball(const QString& arch, const QString& variant);
+
+    void setMirrorBase(const QString& mirror);
+    void cancelOngoingRequest();
+
+signals:
+    void fetchStatusChanged(const QString& status);
+    void fetchError(const QString& error);
+    void variantsFetched(const QStringList& variants);
+    void tarballFetched(const QString& tarballs);
+
+private slots:
+    void onVariantsReplyFinished(QNetworkReply* reply);
+    void onTarballReplyFinished(QNetworkReply* reply, const QString& variant);
+
+private:
+    QString m_mirrorBase {QStringLiteral("http://distfiles.gentoo.org/releases")};
+    QNetworkAccessManager m_nam;
+    QPointer<QNetworkReply> m_currentReply;
+};
+
+#endif //STAGEFETCHER_H
\ No newline at end of file
diff --git a/src/modules/stagechoose/stagechoose.conf b/src/modules/stagechoose/stagechoose.conf
new file mode 100644
index 0000000000..59602206da
--- /dev/null
+++ b/src/modules/stagechoose/stagechoose.conf
@@ -0,0 +1,7 @@
+---
+type: viewmodule
+interface: qtplugin
+module: stagechoose
+
+viewmodule:
+  weight: 30
diff --git a/src/modules/stagechoose/stagechoose.schema.yaml b/src/modules/stagechoose/stagechoose.schema.yaml
new file mode 100644
index 0000000000..d1e3d9aa05
--- /dev/null
+++ b/src/modules/stagechoose/stagechoose.schema.yaml
@@ -0,0 +1,17 @@
+---
+type: map
+mapping:
+  type:
+    type: str
+    required: true
+  interface:
+    type: str
+    required: true
+  module:
+    type: str
+    required: true
+  viewmodule:
+    type: map
+    mapping:
+      weight:
+        type: int
