Add AUR Helper option in Applications menu

Adds yay and paru as installable AUR helpers via the Applications menu
This commit is contained in:
unkver 2026-04-19 15:39:06 +03:00
parent cd62eff4a7
commit 0218e586e7
6 changed files with 183 additions and 2 deletions

View File

@ -0,0 +1,100 @@
from typing import TYPE_CHECKING
from archinstall.lib.models.application import AURHelper, AURHelperConfiguration
from archinstall.lib.models.users import User
from archinstall.lib.output import debug
if TYPE_CHECKING:
from archinstall.lib.installer import Installer
class AURHelperApp:
@property
def yay_packages(self) -> list[str]:
return [
'base-devel',
'git',
'go', # required to build yay
]
@property
def paru_packages(self) -> list[str]:
return [
'base-devel',
'git',
'rust', # required to build paru
]
def _write_firstboot_service(
self,
install_session: Installer,
helper: str,
user: User,
) -> None:
"""
Writes a systemd oneshot service that builds and installs the AUR helper
on first boot as the real user, then removes itself.
makepkg cannot run as root and needs a real user session to work
correctly, so we defer it to first boot instead of running it during
the archinstall chroot environment.
The service runs as root so it can write/remove the temporary sudoers
rule, and explicitly su's to the user only for the makepkg step.
"""
repo_url = f'https://aur.archlinux.org/{helper}.git'
build_dir = f'/home/{user.username}/{helper}-build'
service_name = f'aur-install-{helper}'
sudoers_rule = f'/etc/sudoers.d/99-aur-{helper}-tmp'
service_content = f"""\
[Unit]
Description=Install {helper} AUR helper (first boot)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
# Run as root so we can write/remove the sudoers rule
ExecStartPre=/bin/bash -c 'echo "{user.username} ALL=(ALL) NOPASSWD: /usr/bin/pacman" > {sudoers_rule} && chmod 440 {sudoers_rule}'
# makepkg is invoked via su to the actual user.
ExecStart=/bin/su - {user.username} -c 'git clone {repo_url} {build_dir} && cd {build_dir} && makepkg -si --noconfirm && rm -rf {build_dir}'
ExecStartPost=/bin/rm -f {sudoers_rule}
ExecStartPost=/bin/rm -f /etc/systemd/system/{service_name}.service
ExecStartPost=/bin/systemctl daemon-reload
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
"""
service_path = install_session.target / 'etc' / 'systemd' / 'system' / f'{service_name}.service'
service_path.write_text(service_content)
install_session.enable_service([f'{service_name}.service'])
debug(f'First-boot service written for {helper}, will install as {user.username}')
def install(
self,
install_session: Installer,
AUR_config: AURHelperConfiguration,
users: list[User] | None = None,
) -> None:
debug(f'Installing AUR helper: {AUR_config.AUR_helper.value}')
if AUR_config.AUR_helper == AURHelper.NO_AUR_HELPER:
debug('No AUR helper selected, skipping installation.')
return
if not users:
debug('No users provided, skipping AUR helper installation.')
return
user = users[0]
match AUR_config.AUR_helper:
case AURHelper.YAY:
install_session.add_additional_packages(self.yay_packages)
self._write_firstboot_service(install_session, 'yay', user)
case AURHelper.PARU:
install_session.add_additional_packages(self.paru_packages)
self._write_firstboot_service(install_session, 'paru', user)

View File

@ -6,7 +6,9 @@ from archinstall.applications.firewall import FirewallApp
from archinstall.applications.fonts import FontsApp
from archinstall.applications.power_management import PowerManagementApp
from archinstall.applications.print_service import PrintServiceApp
from archinstall.applications.AUR_helper import AURHelperApp
from archinstall.lib.models import Audio
from archinstall.lib.models.application import AURHelper
from archinstall.lib.models.application import ApplicationConfiguration
from archinstall.lib.models.users import User
@ -38,6 +40,13 @@ class ApplicationHandler:
if app_config.print_service_config and app_config.print_service_config.enabled:
PrintServiceApp().install(install_session)
if app_config.AUR_helper_config and app_config.AUR_helper_config.AUR_helper != AURHelper.NO_AUR_HELPER:
AURHelperApp().install(
install_session,
app_config.AUR_helper_config,
users,
)
if app_config.firewall_config:
FirewallApp().install(
install_session,

View File

@ -7,6 +7,8 @@ from archinstall.lib.models.application import (
ApplicationConfiguration,
Audio,
AudioConfiguration,
AURHelper,
AURHelperConfiguration,
BluetoothConfiguration,
Firewall,
FirewallConfiguration,
@ -66,6 +68,12 @@ class ApplicationMenu(AbstractSubMenu[ApplicationConfiguration]):
preview_action=self._prev_print_service,
key='print_service_config',
),
MenuItem(
text=tr('AUR helper'),
action=select_AUR_helper,
preview_action=self._prev_AUR_helper,
key='AUR_helper_config',
),
MenuItem(
text=tr('Power management'),
action=select_power_management,
@ -118,6 +126,12 @@ class ApplicationMenu(AbstractSubMenu[ApplicationConfiguration]):
return output
return None
def _prev_AUR_helper(self, item: MenuItem) -> str | None:
if item.value is not None:
config: AURHelperConfiguration = item.value
return f'{tr("AUR helper")}: {config.AUR_helper.value}'
return None
def _prev_firewall(self, item: MenuItem) -> str | None:
if item.value is not None:
config: FirewallConfiguration = item.value
@ -214,6 +228,28 @@ async def select_audio(preset: AudioConfiguration | None = None) -> AudioConfigu
raise ValueError('Unhandled result type')
async def select_AUR_helper(preset: AURHelperConfiguration | None = None) -> AURHelperConfiguration | None:
items = [MenuItem(a.value, value=a) for a in AURHelper]
group = MenuItemGroup(items)
if preset:
group.set_focus_by_value(preset.AUR_helper)
result = await Selection[AURHelper](
group,
header=tr('Select an AUR helper'),
allow_skip=True,
).show()
match result.type_:
case ResultType.Skip:
return preset
case ResultType.Selection:
return AURHelperConfiguration(AUR_helper=result.get_value())
case ResultType.Reset:
raise ValueError('Unhandled result type')
async def select_firewall(preset: FirewallConfiguration | None = None) -> FirewallConfiguration | None:
group = MenuItemGroup.from_enum(Firewall)

View File

@ -352,6 +352,11 @@ class GlobalMenu(AbstractMenu[None]):
output += f'{tr("Print service")}: '
output += tr('Enabled') if app_config.print_service_config.enabled else tr('Disabled')
output += '\n'
if app_config.AUR_helper_config:
AUR_helper_config = app_config.AUR_helper_config
output += f'{tr("AUR helper")}: {AUR_helper_config.AUR_helper.value}'
output += '\n'
if app_config.power_management_config:
power_management_config = app_config.power_management_config

View File

@ -31,6 +31,13 @@ class AudioConfigSerialization(TypedDict):
class PrintServiceConfigSerialization(TypedDict):
enabled: bool
class AURHelper(StrEnum):
NO_AUR_HELPER = 'No AUR helper'
YAY = auto()
PARU = auto()
class AURHelperConfigSerialization(TypedDict):
AUR_helper: str
class Firewall(StrEnum):
UFW = auto()
@ -79,6 +86,7 @@ class ApplicationSerialization(TypedDict):
audio_config: NotRequired[AudioConfigSerialization]
power_management_config: NotRequired[PowerManagementConfigSerialization]
print_service_config: NotRequired[PrintServiceConfigSerialization]
AUR_helper_config: NotRequired[AURHelperConfigSerialization]
firewall_config: NotRequired[FirewallConfigSerialization]
fonts_config: NotRequired[FontsConfigSerialization]
@ -139,6 +147,22 @@ class PrintServiceConfiguration:
return cls(arg['enabled'])
@dataclass
class AURHelperConfiguration:
AUR_helper: AURHelper
def json(self) -> AURHelperConfigSerialization:
return {
'AUR_helper': self.AUR_helper.value,
}
@classmethod
def parse_arg(cls, arg: dict[str, Any]) -> Self:
return cls(
AURHelper(arg['AUR_helper']),
)
@dataclass
class FirewallConfiguration:
firewall: Firewall
@ -188,6 +212,7 @@ class ApplicationConfiguration:
audio_config: AudioConfiguration | None = None
power_management_config: PowerManagementConfiguration | None = None
print_service_config: PrintServiceConfiguration | None = None
AUR_helper_config: AURHelperConfiguration | None = None
firewall_config: FirewallConfiguration | None = None
fonts_config: FontsConfiguration | None = None
@ -215,6 +240,9 @@ class ApplicationConfiguration:
if args and (print_service_config := args.get('print_service_config')) is not None:
app_config.print_service_config = PrintServiceConfiguration.parse_arg(print_service_config)
if args and (AUR_helper_config := args.get('AUR_helper_config')) is not None:
app_config.AUR_helper_config = AURHelperConfiguration.parse_arg(AUR_helper_config)
if args and (firewall_config := args.get('firewall_config')) is not None:
app_config.firewall_config = FirewallConfiguration.parse_arg(firewall_config)
@ -238,10 +266,13 @@ class ApplicationConfiguration:
if self.print_service_config:
config['print_service_config'] = self.print_service_config.json()
if self.AUR_helper_config:
config['AUR_helper_config'] = self.AUR_helper_config.json()
if self.firewall_config:
config['firewall_config'] = self.firewall_config.json()
if self.fonts_config:
config['fonts_config'] = self.fonts_config.json()
return config
return config

View File

@ -131,7 +131,7 @@ def perform_installation(
auth_handler.setup_auth(installation, config.auth_config, config.hostname)
if app_config := config.app_config:
application_handler.install_applications(installation, app_config)
application_handler.install_applications(installation, app_config, users)
if profile_config := config.profile_config:
profile_handler.install_profile_config(installation, profile_config)