Add Pacman settings submenu with Color and ParallelDownloads (#4404)

* Add Pacman settings submenu with Color and ParallelDownloads

Replace the standalone Parallel Downloads menu item with a Pacman
submenu containing ParallelDownloads (default 5) and Color (default on).
Settings are applied to both live and target system pacman.conf.
Hidden behind --advanced flag.

Backward compatible with old configs using "parallel_downloads" key.

* Skip _apply_to_live when user exits Pacman menu without changes

* Use TypedDict for PacmanConfiguration serialization

* Show Pacman menu by default, keep ParallelDownloads behind --advanced
This commit is contained in:
Softer 2026-04-13 11:37:51 +03:00 committed by GitHub
parent bf9b9cb7c1
commit 80da3f14a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 319 additions and 71 deletions

View File

@ -22,6 +22,7 @@ from archinstall.lib.models.locale import LocaleConfiguration
from archinstall.lib.models.mirrors import MirrorConfiguration from archinstall.lib.models.mirrors import MirrorConfiguration
from archinstall.lib.models.network import NetworkConfiguration from archinstall.lib.models.network import NetworkConfiguration
from archinstall.lib.models.packages import Repository from archinstall.lib.models.packages import Repository
from archinstall.lib.models.pacman import PacmanConfiguration
from archinstall.lib.models.profile import ProfileConfiguration from archinstall.lib.models.profile import ProfileConfiguration
from archinstall.lib.models.users import Password, User, UserSerialization from archinstall.lib.models.users import Password, User, UserSerialization
from archinstall.lib.output import debug, error, logger, warn from archinstall.lib.output import debug, error, logger, warn
@ -73,7 +74,7 @@ class ArchConfig:
kernels: list[str] = field(default_factory=lambda: ['linux']) kernels: list[str] = field(default_factory=lambda: ['linux'])
ntp: bool = True ntp: bool = True
packages: list[str] = field(default_factory=list) packages: list[str] = field(default_factory=list)
parallel_downloads: int = 0 pacman_config: PacmanConfiguration = field(default_factory=PacmanConfiguration.default)
timezone: str = 'UTC' timezone: str = 'UTC'
services: list[str] = field(default_factory=list) services: list[str] = field(default_factory=list)
custom_commands: list[str] = field(default_factory=list) custom_commands: list[str] = field(default_factory=list)
@ -104,7 +105,7 @@ class ArchConfig:
'kernels': self.kernels, 'kernels': self.kernels,
'ntp': self.ntp, 'ntp': self.ntp,
'packages': self.packages, 'packages': self.packages,
'parallel_downloads': self.parallel_downloads, 'pacman_config': self.pacman_config.json(),
'swap': self.swap, 'swap': self.swap,
'timezone': self.timezone, 'timezone': self.timezone,
'services': self.services, 'services': self.services,
@ -209,8 +210,10 @@ class ArchConfig:
if packages := args_config.get('packages', []): if packages := args_config.get('packages', []):
arch_config.packages = packages arch_config.packages = packages
if parallel_downloads := args_config.get('parallel_downloads', 0): if pacman_config := args_config.get('pacman_config', None):
arch_config.parallel_downloads = parallel_downloads arch_config.pacman_config = PacmanConfiguration.parse_arg(pacman_config)
elif parallel_downloads := args_config.get('parallel_downloads', 0):
arch_config.pacman_config = PacmanConfiguration(parallel_downloads=int(parallel_downloads))
swap_arg = args_config.get('swap') swap_arg = args_config.get('swap')
if swap_arg is not None: if swap_arg is not None:

View File

@ -1,5 +1,4 @@
from archinstall.lib.general.general_menu import ( from archinstall.lib.general.general_menu import (
add_number_of_parallel_downloads,
select_archinstall_language, select_archinstall_language,
select_hostname, select_hostname,
select_ntp, select_ntp,
@ -8,7 +7,6 @@ from archinstall.lib.general.general_menu import (
from archinstall.lib.general.system_menu import select_driver, select_kernel, select_swap from archinstall.lib.general.system_menu import select_driver, select_kernel, select_swap
__all__ = [ __all__ = [
'add_number_of_parallel_downloads',
'select_archinstall_language', 'select_archinstall_language',
'select_driver', 'select_driver',
'select_hostname', 'select_hostname',

View File

@ -3,7 +3,6 @@ from enum import Enum
from archinstall.lib.locale.utils import list_timezones from archinstall.lib.locale.utils import list_timezones
from archinstall.lib.menu.helpers import Confirmation, Input, Selection from archinstall.lib.menu.helpers import Confirmation, Input, Selection
from archinstall.lib.output import warn from archinstall.lib.output import warn
from archinstall.lib.pathnames import PACMAN_CONF
from archinstall.lib.translationhandler import Language, tr from archinstall.lib.translationhandler import Language, tr
from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup
from archinstall.tui.ui.result import ResultType from archinstall.tui.ui.result import ResultType
@ -126,55 +125,6 @@ async def select_archinstall_language(languages: list[Language], preset: Languag
raise ValueError('Language selection not handled') raise ValueError('Language selection not handled')
async def add_number_of_parallel_downloads(preset: int = 1) -> int | None:
max_recommended = 5
header = tr('This option enables the number of parallel downloads that can occur during package downloads') + '\n'
header += tr(' - Maximum recommended value : {} ( Allows {} parallel downloads at a time )').format(max_recommended, max_recommended) + '\n\n'
header += tr('Enter the number of parallel downloads to be enabled')
def validator(s: str) -> str | None:
try:
value = int(s)
if 1 <= value <= max_recommended:
return None
return tr('Value must be between 1 and {}').format(max_recommended)
except Exception:
return tr('Please enter a valid number')
result = await Input(
header=header,
allow_skip=True,
allow_reset=True,
validator_callback=validator,
default_value=str(preset),
).show()
downloads = 1
match result.type_:
case ResultType.Skip:
return preset
case ResultType.Reset:
return downloads
case ResultType.Selection:
downloads = int(result.get_value())
with PACMAN_CONF.open() as f:
pacman_conf = f.read().split('\n')
with PACMAN_CONF.open('w') as fwrite:
for line in pacman_conf:
if 'ParallelDownloads' in line:
fwrite.write(f'ParallelDownloads = {downloads}\n')
else:
fwrite.write(f'{line}\n')
return downloads
async def select_post_installation(elapsed_time: float | None = None) -> PostInstallationAction: async def select_post_installation(elapsed_time: float | None = None) -> PostInstallationAction:
header = 'Installation completed' header = 'Installation completed'
if elapsed_time is not None: if elapsed_time is not None:

View File

@ -7,7 +7,7 @@ from archinstall.lib.authentication.authentication_menu import AuthenticationMen
from archinstall.lib.bootloader.bootloader_menu import BootloaderMenu from archinstall.lib.bootloader.bootloader_menu import BootloaderMenu
from archinstall.lib.configuration import save_config from archinstall.lib.configuration import save_config
from archinstall.lib.disk.disk_menu import DiskLayoutConfigurationMenu from archinstall.lib.disk.disk_menu import DiskLayoutConfigurationMenu
from archinstall.lib.general.general_menu import add_number_of_parallel_downloads, select_hostname, select_ntp, select_timezone from archinstall.lib.general.general_menu import select_hostname, select_ntp, select_timezone
from archinstall.lib.general.system_menu import select_kernel, select_swap from archinstall.lib.general.system_menu import select_kernel, select_swap
from archinstall.lib.hardware import SysInfo from archinstall.lib.hardware import SysInfo
from archinstall.lib.locale.locale_menu import LocaleMenu from archinstall.lib.locale.locale_menu import LocaleMenu
@ -22,11 +22,13 @@ from archinstall.lib.models.locale import LocaleConfiguration
from archinstall.lib.models.mirrors import MirrorConfiguration from archinstall.lib.models.mirrors import MirrorConfiguration
from archinstall.lib.models.network import NetworkConfiguration, NicType from archinstall.lib.models.network import NetworkConfiguration, NicType
from archinstall.lib.models.packages import Repository from archinstall.lib.models.packages import Repository
from archinstall.lib.models.pacman import PacmanConfiguration
from archinstall.lib.models.profile import ProfileConfiguration from archinstall.lib.models.profile import ProfileConfiguration
from archinstall.lib.network.network_menu import select_network from archinstall.lib.network.network_menu import select_network
from archinstall.lib.output import FormattedOutput from archinstall.lib.output import FormattedOutput
from archinstall.lib.packages.packages import list_available_packages, select_additional_packages from archinstall.lib.packages.packages import list_available_packages, select_additional_packages
from archinstall.lib.pacman.config import PacmanConfig from archinstall.lib.pacman.config import PacmanConfig
from archinstall.lib.pacman.pacman_menu import PacmanMenu
from archinstall.lib.translationhandler import Language, tr, translation_handler from archinstall.lib.translationhandler import Language, tr, translation_handler
from archinstall.tui.ui.components import tui from archinstall.tui.ui.components import tui
from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup
@ -38,11 +40,13 @@ class GlobalMenu(AbstractMenu[None]):
arch_config: ArchConfig, arch_config: ArchConfig,
mirror_list_handler: MirrorListHandler | None = None, mirror_list_handler: MirrorListHandler | None = None,
skip_boot: bool = False, skip_boot: bool = False,
advanced: bool = False,
title: str | None = None, title: str | None = None,
) -> None: ) -> None:
self._arch_config = arch_config self._arch_config = arch_config
self._mirror_list_handler = mirror_list_handler self._mirror_list_handler = mirror_list_handler
self._skip_boot = skip_boot self._skip_boot = skip_boot
self._advanced = advanced
self._uefi = SysInfo.has_uefi() self._uefi = SysInfo.has_uefi()
menu_options = self._get_menu_options() menu_options = self._get_menu_options()
@ -138,11 +142,11 @@ class GlobalMenu(AbstractMenu[None]):
key='network_config', key='network_config',
), ),
MenuItem( MenuItem(
text=tr('Parallel Downloads'), text=tr('Pacman'),
action=add_number_of_parallel_downloads, action=self._pacman_configuration,
value=1, value=PacmanConfiguration.default(),
preview_action=self._prev_parallel_dw, preview_action=self._prev_pacman_config,
key='parallel_downloads', key='pacman_config',
), ),
MenuItem( MenuItem(
text=tr('Additional packages'), text=tr('Additional packages'),
@ -413,10 +417,18 @@ class GlobalMenu(AbstractMenu[None]):
return f'{tr("Hostname")}: {item.value}' return f'{tr("Hostname")}: {item.value}'
return None return None
def _prev_parallel_dw(self, item: MenuItem) -> str | None: async def _pacman_configuration(self, preset: PacmanConfiguration) -> PacmanConfiguration | None:
if item.value is not None: return await PacmanMenu(preset, advanced=self._advanced).show()
return f'{tr("Parallel Downloads")}: {item.value}'
return None def _prev_pacman_config(self, item: MenuItem) -> str | None:
if not item.value:
return None
config: PacmanConfiguration = item.value
output = ''
if self._advanced:
output += '{}: {}\n'.format(tr('Parallel Downloads'), config.parallel_downloads)
output += '{}: {}'.format(tr('Color'), config.color)
return output
def _prev_kernel(self, item: MenuItem) -> str | None: def _prev_kernel(self, item: MenuItem) -> str | None:
if item.value: if item.value:

View File

@ -48,6 +48,7 @@ from archinstall.lib.models.locale import LocaleConfiguration
from archinstall.lib.models.mirrors import MirrorConfiguration from archinstall.lib.models.mirrors import MirrorConfiguration
from archinstall.lib.models.network import Nic from archinstall.lib.models.network import Nic
from archinstall.lib.models.packages import Repository from archinstall.lib.models.packages import Repository
from archinstall.lib.models.pacman import PacmanConfiguration
from archinstall.lib.models.users import User from archinstall.lib.models.users import User
from archinstall.lib.output import debug, error, info, log, logger, warn from archinstall.lib.output import debug, error, info, log, logger, warn
from archinstall.lib.packages.packages import installed_package from archinstall.lib.packages.packages import installed_package
@ -886,6 +887,7 @@ class Installer:
mkinitcpio: bool = True, mkinitcpio: bool = True,
hostname: str | None = None, hostname: str | None = None,
locale_config: LocaleConfiguration | None = LocaleConfiguration.default(), locale_config: LocaleConfiguration | None = LocaleConfiguration.default(),
pacman_config: PacmanConfiguration | None = None,
) -> None: ) -> None:
if self._disk_config.lvm_config: if self._disk_config.lvm_config:
lvm = 'lvm2' lvm = 'lvm2'
@ -932,6 +934,9 @@ class Installer:
pacman_conf.persist() pacman_conf.persist()
if pacman_config:
pacman_conf.configure(pacman_config)
# Periodic TRIM may improve the performance and longevity of SSDs whilst # Periodic TRIM may improve the performance and longevity of SSDs whilst
# having no adverse effect on other devices. Most distributions enable # having no adverse effect on other devices. Most distributions enable
# periodic TRIM by default. # periodic TRIM by default.

View File

@ -0,0 +1,42 @@
from dataclasses import dataclass
from typing import Self, TypedDict
from archinstall.lib.translationhandler import tr
class PacmanConfigSerialization(TypedDict):
parallel_downloads: int
color: bool
@dataclass
class PacmanConfiguration:
parallel_downloads: int = 5
color: bool = True
@classmethod
def default(cls) -> Self:
return cls()
def json(self) -> PacmanConfigSerialization:
return {
'parallel_downloads': self.parallel_downloads,
'color': self.color,
}
def preview(self) -> str:
color_str = str(self.color)
output = '{}: {}\n'.format(tr('Parallel Downloads'), self.parallel_downloads)
output += '{}: {}'.format(tr('Color'), color_str)
return output
@classmethod
def parse_arg(cls, args: PacmanConfigSerialization) -> Self:
config = cls.default()
if 'parallel_downloads' in args:
config.parallel_downloads = int(args['parallel_downloads'])
if 'color' in args:
config.color = bool(args['color'])
return config

View File

@ -2,6 +2,7 @@ import re
from pathlib import Path from pathlib import Path
from archinstall.lib.models.packages import Repository from archinstall.lib.models.packages import Repository
from archinstall.lib.models.pacman import PacmanConfiguration
from archinstall.lib.pathnames import PACMAN_CONF from archinstall.lib.pathnames import PACMAN_CONF
@ -50,5 +51,23 @@ class PacmanConfig:
f.writelines(content) f.writelines(content)
def persist(self) -> None: def persist(self) -> None:
if self._repositories and self._config_remote_path: if self._config_remote_path:
PACMAN_CONF.copy(self._config_remote_path, preserve_metadata=True) PACMAN_CONF.copy(self._config_remote_path, preserve_metadata=True)
def configure(self, pacman_config: PacmanConfiguration) -> None:
"""Apply PacmanConfiguration (Color, ParallelDownloads) to the target system's pacman.conf."""
if not self._config_remote_path or not self._config_remote_path.exists():
return
content = self._config_remote_path.read_text().splitlines()
result = []
for line in content:
if re.match(r'^#?\s*ParallelDownloads', line):
result.append(f'ParallelDownloads = {pacman_config.parallel_downloads}')
elif re.match(r'^#?\s*Color\s*$', line):
result.append('Color' if pacman_config.color else '#Color')
else:
result.append(line)
self._config_remote_path.write_text('\n'.join(result) + '\n')

View File

@ -0,0 +1,117 @@
from typing import override
from archinstall.lib.menu.abstract_menu import AbstractSubMenu
from archinstall.lib.menu.helpers import Confirmation, Input
from archinstall.lib.models.pacman import PacmanConfiguration
from archinstall.lib.pathnames import PACMAN_CONF
from archinstall.lib.translationhandler import tr
from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup
from archinstall.tui.ui.result import ResultType
class PacmanMenu(AbstractSubMenu[PacmanConfiguration]):
def __init__(
self,
pacman_conf: PacmanConfiguration,
advanced: bool = False,
):
self._pacman_conf = pacman_conf
self._advanced = advanced
menu_options = self._define_menu_options()
self._item_group = MenuItemGroup(menu_options, sort_items=False, checkmarks=True)
super().__init__(
self._item_group,
config=self._pacman_conf,
allow_reset=True,
)
def _define_menu_options(self) -> list[MenuItem]:
return [
MenuItem(
text=tr('Parallel Downloads'),
action=select_parallel_downloads,
value=self._pacman_conf.parallel_downloads,
preview_action=lambda item: str(item.get_value()),
key='parallel_downloads',
enabled=self._advanced,
),
MenuItem(
text=tr('Color'),
action=select_color,
value=self._pacman_conf.color,
preview_action=lambda item: str(item.get_value()),
key='color',
),
]
@override
async def show(self) -> PacmanConfiguration | None:
config = await super().show()
if config is None:
return PacmanConfiguration.default()
_apply_to_live(config.parallel_downloads)
return config
def _apply_to_live(parallel_downloads: int) -> None:
"""Apply ParallelDownloads to live system pacman.conf for faster installation."""
with PACMAN_CONF.open() as f:
pacman_conf = f.read().split('\n')
with PACMAN_CONF.open('w') as fwrite:
for line in pacman_conf:
if 'ParallelDownloads' in line:
fwrite.write(f'ParallelDownloads = {parallel_downloads}\n')
else:
fwrite.write(f'{line}\n')
async def select_parallel_downloads(preset: int = 5) -> int | None:
max_recommended = 10
header = tr('Enter the number of parallel downloads (1-{})').format(max_recommended)
def validator(s: str) -> str | None:
try:
value = int(s)
if 1 <= value <= max_recommended:
return None
return tr('Value must be between 1 and {}').format(max_recommended)
except Exception:
return tr('Please enter a valid number')
result = await Input(
header=header,
allow_skip=True,
allow_reset=True,
validator_callback=validator,
default_value=str(preset),
).show()
match result.type_:
case ResultType.Skip:
return preset
case ResultType.Reset:
return 5
case ResultType.Selection:
return int(result.get_value())
async def select_color(preset: bool = True) -> bool | None:
result = await Confirmation(
header=tr('Enable colored output for pacman'),
preset=preset,
allow_skip=True,
).show()
match result.type_:
case ResultType.Skip:
return preset
case ResultType.Reset:
return True
case ResultType.Selection:
return result.get_value()

View File

@ -830,6 +830,19 @@ msgstr ""
msgid "Parallel Downloads" msgid "Parallel Downloads"
msgstr "" msgstr ""
msgid "Pacman"
msgstr ""
msgid "Color"
msgstr ""
#, python-brace-format
msgid "Enter the number of parallel downloads (1-{})"
msgstr ""
msgid "Enable colored output for pacman"
msgstr ""
msgid "ESC to skip" msgid "ESC to skip"
msgstr "" msgstr ""
@ -2184,3 +2197,36 @@ msgstr ""
#, python-brace-format #, python-brace-format
msgid "Environment type: {} {}" msgid "Environment type: {} {}"
msgstr "" msgstr ""
msgid "Input cannot be empty"
msgstr ""
msgid "Recommended"
msgstr ""
msgid "Package"
msgstr ""
msgid "Curated selection of KDE Plasma packages"
msgstr ""
msgid "Dependencies"
msgstr ""
msgid "Package group"
msgstr ""
msgid "Extensive KDE Plasma installation"
msgstr ""
msgid "Packages in group"
msgstr ""
msgid "Minimal KDE Plasma installation"
msgstr ""
msgid "Description"
msgstr ""
msgid "Select a flavor of KDE Plasma to install"
msgstr ""

View File

@ -823,6 +823,19 @@ msgstr "Некоректне введення! Повторіть спробу
msgid "Parallel Downloads" msgid "Parallel Downloads"
msgstr "Паралельні Завантаження" msgstr "Паралельні Завантаження"
msgid "Pacman"
msgstr "Pacman"
msgid "Color"
msgstr "Колір"
#, python-brace-format
msgid "Enter the number of parallel downloads (1-{})"
msgstr "Введіть кількість паралельних завантажень (1-{})"
msgid "Enable colored output for pacman"
msgstr "Увімкнути кольоровий вивід для pacman"
msgid "ESC to skip" msgid "ESC to skip"
msgstr "ESC, щоб пропустити" msgstr "ESC, щоб пропустити"
@ -2119,3 +2132,46 @@ msgstr "Надійність пароля: Сильна"
msgid "The selected desktop profile requires a regular user to log in via the greeter" msgid "The selected desktop profile requires a regular user to log in via the greeter"
msgstr "Обраний профіль робочого столу вимагає входу звичайного користувача через менеджер входу" msgstr "Обраний профіль робочого столу вимагає входу звичайного користувача через менеджер входу"
#, fuzzy, python-brace-format
msgid "Environment type: {} {}"
msgstr "Тип середовища: {}"
#, fuzzy
msgid "Input cannot be empty"
msgstr "Значення не може бути пустим"
msgid "Recommended"
msgstr ""
#, fuzzy
msgid "Package"
msgstr "Група пакетів:"
msgid "Curated selection of KDE Plasma packages"
msgstr ""
msgid "Dependencies"
msgstr ""
#, fuzzy
msgid "Package group"
msgstr "Група пакетів:"
msgid "Extensive KDE Plasma installation"
msgstr ""
#, fuzzy
msgid "Packages in group"
msgstr "Група пакетів:"
msgid "Minimal KDE Plasma installation"
msgstr ""
#, fuzzy
msgid "Description"
msgstr "Шифрування диска"
#, fuzzy
msgid "Select a flavor of KDE Plasma to install"
msgstr "Оберіть завантажувач для встановлення"

View File

@ -39,12 +39,10 @@ def show_menu(
arch_config_handler.config, arch_config_handler.config,
mirror_list_handler, mirror_list_handler,
arch_config_handler.args.skip_boot, arch_config_handler.args.skip_boot,
advanced=arch_config_handler.args.advanced,
title=title_text, title=title_text,
) )
if not arch_config_handler.args.advanced:
global_menu.set_enabled('parallel_downloads', False)
result: ArchConfig | None = tui.run(global_menu) result: ArchConfig | None = tui.run(global_menu)
if result is None: if result is None:
sys.exit(0) sys.exit(0)
@ -106,6 +104,7 @@ def perform_installation(
mkinitcpio=run_mkinitcpio, mkinitcpio=run_mkinitcpio,
hostname=arch_config_handler.config.hostname, hostname=arch_config_handler.config.hostname,
locale_config=locale_config, locale_config=locale_config,
pacman_config=config.pacman_config,
) )
if mirror_config := config.mirror_config: if mirror_config := config.mirror_config:

View File

@ -22,6 +22,7 @@ from archinstall.lib.models.locale import LocaleConfiguration
from archinstall.lib.models.mirrors import CustomRepository, CustomServer, MirrorConfiguration, MirrorRegion, SignCheck, SignOption from archinstall.lib.models.mirrors import CustomRepository, CustomServer, MirrorConfiguration, MirrorRegion, SignCheck, SignOption
from archinstall.lib.models.network import NetworkConfiguration, Nic, NicType from archinstall.lib.models.network import NetworkConfiguration, Nic, NicType
from archinstall.lib.models.packages import Repository from archinstall.lib.models.packages import Repository
from archinstall.lib.models.pacman import PacmanConfiguration
from archinstall.lib.models.profile import ProfileConfiguration from archinstall.lib.models.profile import ProfileConfiguration
from archinstall.lib.models.users import Password, User from archinstall.lib.models.users import Password, User
from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.lib.profile.profiles_handler import profile_handler
@ -234,7 +235,7 @@ def test_config_file_parsing(
kernels=['linux-zen'], kernels=['linux-zen'],
ntp=True, ntp=True,
packages=['firefox'], packages=['firefox'],
parallel_downloads=66, pacman_config=PacmanConfiguration(parallel_downloads=66),
swap=ZramConfiguration(enabled=False), swap=ZramConfiguration(enabled=False),
timezone='UTC', timezone='UTC',
services=['service_1', 'service_2'], services=['service_1', 'service_2'],