diff --git a/archinstall/applications/AUR_helper.py b/archinstall/applications/AUR_helper.py new file mode 100644 index 0000000000..fb3f875061 --- /dev/null +++ b/archinstall/applications/AUR_helper.py @@ -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) \ No newline at end of file diff --git a/archinstall/lib/applications/application_handler.py b/archinstall/lib/applications/application_handler.py index 7fcf5dbacb..c44bfceb90 100644 --- a/archinstall/lib/applications/application_handler.py +++ b/archinstall/lib/applications/application_handler.py @@ -6,7 +6,9 @@ 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 @@ def install_applications(self, install_session: Installer, app_config: Applicati 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, diff --git a/archinstall/lib/applications/application_menu.py b/archinstall/lib/applications/application_menu.py index 990dcda7cc..92c355339d 100644 --- a/archinstall/lib/applications/application_menu.py +++ b/archinstall/lib/applications/application_menu.py @@ -7,6 +7,8 @@ ApplicationConfiguration, Audio, AudioConfiguration, + AURHelper, + AURHelperConfiguration, BluetoothConfiguration, Firewall, FirewallConfiguration, @@ -66,6 +68,12 @@ def _define_menu_options(self) -> list[MenuItem]: 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 @@ def _prev_print_service(self, item: MenuItem) -> str | None: 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) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 2baef93d41..1e324a5e31 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -352,6 +352,11 @@ def _prev_applications(self, item: MenuItem) -> str | 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 diff --git a/archinstall/lib/models/application.py b/archinstall/lib/models/application.py index 04d1a3424d..3804047a0c 100644 --- a/archinstall/lib/models/application.py +++ b/archinstall/lib/models/application.py @@ -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 @@ def parse_arg(cls, arg: PrintServiceConfigSerialization) -> Self: 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 @@ def parse_arg( 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 @@ def json(self) -> ApplicationSerialization: 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 \ No newline at end of file diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 3413372253..ea4ba08c9b 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -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)