From 505d1025051bd208fa8667b109ca8b94a51c38a9 Mon Sep 17 00:00:00 2001 From: VykosMolt Date: Mon, 31 Aug 2026 18:24:08 +0200 Subject: [PATCH 1/5] Name the seat access options after the seat managers Arch has two ways of giving a compositor access to the seat: seatd, or systemd-logind. The menu offered "seatd" and "polkit", but polkit is not a seat manager. It is part of the authorization machinery: systemd lists polkit as an optional dependency, and logind checks with it before letting an unprivileged user act, so polkit has to be installed for the logind route to work as intended on Arch. That made it the thing to install, never the thing being chosen. The second option is now called systemd-logind, and SeatAccess carries what each choice actually needs: which packages to install and which services to enable. Configurations written before this still say 'polkit', and are read back as systemd-logind, so a saved choice is not silently discarded. Enabling polkit was doing nothing in any case. Its unit is static, so `systemctl enable polkit` only prints "The unit files have no installation config" and exits 0. The same is true of systemd-logind, which is started on demand, so neither has a service to enable. While the saved value was being read: select_seat_access() handed the stored string to set_default_by_value(), which compares it against menu items holding SeatAccess members. A str is never equal to one, so a previously chosen option was never pre-selected. --- .../default_profiles/desktops/utils.py | 66 ++++++++++++++++- tests/test_seat_access.py | 73 +++++++++++++++++++ 2 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 tests/test_seat_access.py diff --git a/archinstall/default_profiles/desktops/utils.py b/archinstall/default_profiles/desktops/utils.py index b179ad5061..f186a173ab 100644 --- a/archinstall/default_profiles/desktops/utils.py +++ b/archinstall/default_profiles/desktops/utils.py @@ -1,16 +1,69 @@ from enum import Enum +from archinstall.default_profiles.profile import CustomSetting, Profile from archinstall.lib.installer import Installer +from archinstall.lib.log import warn from archinstall.lib.menu.helpers import Selection from archinstall.lib.models.users import User from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.result import ResultType +# Before this option was named after the seat manager it was labelled 'polkit', +# and saved configurations still contain that word. +_LEGACY_LOGIND_SETTING = 'polkit' + class SeatAccess(Enum): - seatd = 'seatd' - polkit = 'polkit' + """How a compositor is given access to the seat: the keyboard, mouse and + screen it is meant to drive. Arch offers two ways of doing that, and each + one needs different things installed and started.""" + + Seatd = 'seatd' + Logind = 'systemd-logind' + + @classmethod + def from_setting(cls, value: str | None) -> SeatAccess | None: + """Read back what was stored in a profile's custom settings. Returns + None when nothing was chosen, or when the value is not one we know.""" + if value is None: + return None + + if value == _LEGACY_LOGIND_SETTING: + return cls.Logind + + try: + return cls(value) + except ValueError: + warn(f'Unknown seat access setting, ignoring it: {value}') + return None + + @property + def packages(self) -> list[str]: + match self: + case SeatAccess.Seatd: + return ['seatd'] + case SeatAccess.Logind: + # logind is part of systemd, so there is nothing to install for + # it. Arch ships polkit as an optional dependency of systemd, and + # logind checks with polkit before letting an unprivileged user + # act, so it has to be installed for this choice to work. + return ['polkit'] + + @property + def services(self) -> list[str]: + match self: + case SeatAccess.Seatd: + return ['seatd'] + case SeatAccess.Logind: + # logind is started on demand and cannot be enabled: its unit + # has no [Install] section. + return [] + + +def seat_access_of(profile: Profile) -> SeatAccess | None: + """The seat access a profile was configured with, if any.""" + return SeatAccess.from_setting(profile.custom_settings.get(CustomSetting.SeatAccess)) def provision_seat_access( @@ -18,7 +71,9 @@ def provision_seat_access( users: list[User], seat_access: str, ) -> None: - if seat_access == SeatAccess.seatd.value: + # seatd decides who may talk to the hardware by group membership, so the + # people logging in have to be in it. logind needs nothing of the sort. + if SeatAccess.from_setting(seat_access) is SeatAccess.Seatd: for user in users: install_session.arch_chroot(f'usermod -a -G seat {user.username}') @@ -31,7 +86,10 @@ async def select_seat_access(profile_name: str, default: str | None) -> SeatAcce items = [MenuItem(s.value, value=s) for s in SeatAccess] group = MenuItemGroup(items, sort_items=True) - group.set_default_by_value(default) + # The menu items hold SeatAccess members while the saved setting is a plain + # string, so it has to be turned back into a member or nothing matches and + # the previous choice is not pre-selected. + group.set_default_by_value(SeatAccess.from_setting(default)) result = await Selection[SeatAccess]( group, diff --git a/tests/test_seat_access.py b/tests/test_seat_access.py new file mode 100644 index 0000000000..ec6c918fd7 --- /dev/null +++ b/tests/test_seat_access.py @@ -0,0 +1,73 @@ +from typing import Any + +import pytest + +from archinstall.default_profiles.desktops.utils import SeatAccess, provision_seat_access +from archinstall.lib.models.users import Password, User + +class FakeInstaller: + """Records the commands provision_seat_access would run in the target.""" + + def __init__(self) -> None: + self.commands: list[str] = [] + + def arch_chroot(self, cmd: str, *args: Any, **kwargs: Any) -> None: + self.commands.append(cmd) + + +def test_saved_settings_are_read_back() -> None: + assert SeatAccess.from_setting('seatd') is SeatAccess.Seatd + assert SeatAccess.from_setting('systemd-logind') is SeatAccess.Logind + + +def test_the_old_polkit_setting_still_means_logind() -> None: + # Configurations written before this option was renamed say 'polkit', and + # they have to keep working. + assert SeatAccess.from_setting('polkit') is SeatAccess.Logind + + +def test_nothing_chosen_and_nonsense_both_come_back_empty() -> None: + assert SeatAccess.from_setting(None) is None + assert SeatAccess.from_setting('not-a-seat-manager') is None + + +def test_the_menu_offers_only_the_two_seat_managers() -> None: + # The menu is built by iterating the enum, so anything added to it shows up + # as an option. The old 'polkit' value must not reappear as one. + assert [seat.value for seat in SeatAccess] == ['seatd', 'systemd-logind'] + + +def test_seatd_is_installed_and_started() -> None: + assert SeatAccess.Seatd.packages == ['seatd'] + assert SeatAccess.Seatd.services == ['seatd'] + + +def test_logind_installs_polkit_and_starts_nothing() -> None: + # logind ships with systemd and its unit has no [Install] section, so there + # is nothing to install or enable for it. Arch builds systemd against + # polkit, which logind asks for permission checks, so that has to be there. + assert SeatAccess.Logind.packages == ['polkit'] + assert SeatAccess.Logind.services == [] + + +@pytest.mark.parametrize('setting', ['seatd']) +def test_seatd_puts_the_users_in_the_seat_group(setting: str) -> None: + installer = FakeInstaller() + users = [User('alice', Password(plaintext='pw'), False), User('bob', Password(plaintext='pw'), False)] + + provision_seat_access(installer, users, setting) # type: ignore[arg-type] + + assert installer.commands == [ + 'usermod -a -G seat alice', + 'usermod -a -G seat bob', + ] + + +@pytest.mark.parametrize('setting', ['systemd-logind', 'polkit', None]) +def test_logind_needs_no_group_membership(setting: str | None) -> None: + installer = FakeInstaller() + users = [User('alice', Password(plaintext='pw'), False)] + + provision_seat_access(installer, users, setting) # type: ignore[arg-type] + + assert installer.commands == [] From c62b0145a22b3f205e7771f18c50621dc27da95f Mon Sep 17 00:00:00 2001 From: VykosMolt Date: Mon, 31 Aug 2026 18:24:08 +0200 Subject: [PATCH 2/5] Install and enable exactly what the chosen seat manager needs The chosen option was stored as a plain string and then used twice: once as a package to install, once as a service to enable. That only worked because the seatd package and the seatd service happen to share a name, and it hid a real bug. Hyprland listed the string under services but never added it to packages, so choosing seatd there ran `systemctl enable seatd` against a target where the package was never installed. That exits 1 with "Unit seatd.service does not exist", and enable_service() turns a non-zero exit into a ServiceException, which stops the installation. Sway, Hyprland, niri and labwc now ask the chosen option what it needs instead of reusing one string for both jobs, so all four behave the same and each installs whatever it enables. The test that covers this runs over every profile and asserts exactly that, so a profile cannot drift apart from the others again the way Hyprland did. --- .../default_profiles/desktops/hyprland.py | 13 ++++-- .../default_profiles/desktops/labwc.py | 19 ++++---- archinstall/default_profiles/desktops/niri.py | 19 ++++---- archinstall/default_profiles/desktops/sway.py | 19 ++++---- tests/test_seat_access.py | 44 ++++++++++++++++--- 5 files changed, 78 insertions(+), 36 deletions(-) diff --git a/archinstall/default_profiles/desktops/hyprland.py b/archinstall/default_profiles/desktops/hyprland.py index 0bf46b96db..c7bc9c325a 100644 --- a/archinstall/default_profiles/desktops/hyprland.py +++ b/archinstall/default_profiles/desktops/hyprland.py @@ -1,6 +1,6 @@ from typing import override -from archinstall.default_profiles.desktops.utils import select_seat_access +from archinstall.default_profiles.desktops.utils import seat_access_of, select_seat_access from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType @@ -18,7 +18,7 @@ def __init__(self) -> None: @property @override def packages(self) -> list[str]: - return [ + packages = [ 'hyprland', 'dunst', 'kitty', @@ -33,6 +33,11 @@ def packages(self) -> list[str]: 'slurp', ] + if seat := seat_access_of(self): + packages += seat.packages + + return packages + @property @override def default_greeter_type(self) -> GreeterType: @@ -41,8 +46,8 @@ def default_greeter_type(self) -> GreeterType: @property @override def services(self) -> list[str]: - if pref := self.custom_settings.get(CustomSetting.SeatAccess, None): - return [pref] + if seat := seat_access_of(self): + return seat.services return [] @override diff --git a/archinstall/default_profiles/desktops/labwc.py b/archinstall/default_profiles/desktops/labwc.py index 48fe344e13..72795eac6b 100644 --- a/archinstall/default_profiles/desktops/labwc.py +++ b/archinstall/default_profiles/desktops/labwc.py @@ -1,6 +1,6 @@ from typing import override -from archinstall.default_profiles.desktops.utils import select_seat_access +from archinstall.default_profiles.desktops.utils import seat_access_of, select_seat_access from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType @@ -18,14 +18,15 @@ def __init__(self) -> None: @property @override def packages(self) -> list[str]: - additional = [] - if seat := self.custom_settings.get(CustomSetting.SeatAccess, None): - additional = [seat] - - return [ + packages = [ 'alacritty', 'labwc', - ] + additional + ] + + if seat := seat_access_of(self): + packages += seat.packages + + return packages @property @override @@ -35,8 +36,8 @@ def default_greeter_type(self) -> GreeterType: @property @override def services(self) -> list[str]: - if pref := self.custom_settings.get(CustomSetting.SeatAccess, None): - return [pref] + if seat := seat_access_of(self): + return seat.services return [] @override diff --git a/archinstall/default_profiles/desktops/niri.py b/archinstall/default_profiles/desktops/niri.py index d8db75f5da..db9f9b6d08 100644 --- a/archinstall/default_profiles/desktops/niri.py +++ b/archinstall/default_profiles/desktops/niri.py @@ -1,6 +1,6 @@ from typing import override -from archinstall.default_profiles.desktops.utils import select_seat_access +from archinstall.default_profiles.desktops.utils import seat_access_of, select_seat_access from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType @@ -18,11 +18,7 @@ def __init__(self) -> None: @property @override def packages(self) -> list[str]: - additional = [] - if seat := self.custom_settings.get(CustomSetting.SeatAccess, None): - additional = [seat] - - return [ + packages = [ 'niri', 'alacritty', 'fuzzel', @@ -33,7 +29,12 @@ def packages(self) -> list[str]: 'swayidle', 'swaylock', 'xdg-desktop-portal-gnome', - ] + additional + ] + + if seat := seat_access_of(self): + packages += seat.packages + + return packages @property @override @@ -43,8 +44,8 @@ def default_greeter_type(self) -> GreeterType: @property @override def services(self) -> list[str]: - if pref := self.custom_settings.get(CustomSetting.SeatAccess, None): - return [pref] + if seat := seat_access_of(self): + return seat.services return [] @override diff --git a/archinstall/default_profiles/desktops/sway.py b/archinstall/default_profiles/desktops/sway.py index 7a038dc0ba..9156f845ad 100644 --- a/archinstall/default_profiles/desktops/sway.py +++ b/archinstall/default_profiles/desktops/sway.py @@ -1,6 +1,6 @@ from typing import override -from archinstall.default_profiles.desktops.utils import select_seat_access +from archinstall.default_profiles.desktops.utils import seat_access_of, select_seat_access from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType @@ -18,11 +18,7 @@ def __init__(self) -> None: @property @override def packages(self) -> list[str]: - additional = [] - if seat := self.custom_settings.get(CustomSetting.SeatAccess, None): - additional = [seat] - - return [ + packages = [ 'sway', 'swaybg', 'swaylock', @@ -35,7 +31,12 @@ def packages(self) -> list[str]: 'pavucontrol', 'foot', 'xorg-xwayland', - ] + additional + ] + + if seat := seat_access_of(self): + packages += seat.packages + + return packages @property @override @@ -45,8 +46,8 @@ def default_greeter_type(self) -> GreeterType: @property @override def services(self) -> list[str]: - if pref := self.custom_settings.get(CustomSetting.SeatAccess, None): - return [pref] + if seat := seat_access_of(self): + return seat.services return [] @override diff --git a/tests/test_seat_access.py b/tests/test_seat_access.py index ec6c918fd7..dacb6bdeb1 100644 --- a/tests/test_seat_access.py +++ b/tests/test_seat_access.py @@ -1,10 +1,20 @@ +from collections.abc import Callable from typing import Any import pytest +from archinstall.default_profiles.desktops.hyprland import HyprlandProfile +from archinstall.default_profiles.desktops.labwc import LabwcProfile +from archinstall.default_profiles.desktops.niri import NiriProfile +from archinstall.default_profiles.desktops.sway import SwayProfile from archinstall.default_profiles.desktops.utils import SeatAccess, provision_seat_access +from archinstall.default_profiles.profile import CustomSetting, Profile from archinstall.lib.models.users import Password, User +# Every profile that asks the user how the compositor should reach the hardware. +SEAT_PROFILES: list[Callable[[], Profile]] = [SwayProfile, HyprlandProfile, NiriProfile, LabwcProfile] + + class FakeInstaller: """Records the commands provision_seat_access would run in the target.""" @@ -15,6 +25,12 @@ def arch_chroot(self, cmd: str, *args: Any, **kwargs: Any) -> None: self.commands.append(cmd) +def _profile_with(profile_type: Callable[[], Profile], setting: str | None) -> Profile: + profile = profile_type() + profile.custom_settings[CustomSetting.SeatAccess] = setting + return profile + + def test_saved_settings_are_read_back() -> None: assert SeatAccess.from_setting('seatd') is SeatAccess.Seatd assert SeatAccess.from_setting('systemd-logind') is SeatAccess.Logind @@ -44,18 +60,36 @@ def test_seatd_is_installed_and_started() -> None: def test_logind_installs_polkit_and_starts_nothing() -> None: # logind ships with systemd and its unit has no [Install] section, so there - # is nothing to install or enable for it. Arch builds systemd against - # polkit, which logind asks for permission checks, so that has to be there. + # is nothing to install or enable for it. Arch ships polkit as an optional + # dependency of systemd, and logind checks with it before letting an + # unprivileged user act, so that has to be installed. assert SeatAccess.Logind.packages == ['polkit'] assert SeatAccess.Logind.services == [] -@pytest.mark.parametrize('setting', ['seatd']) -def test_seatd_puts_the_users_in_the_seat_group(setting: str) -> None: +@pytest.mark.parametrize('profile_type', SEAT_PROFILES) +@pytest.mark.parametrize('setting', ['seatd', 'systemd-logind', 'polkit']) +def test_a_profile_installs_whatever_it_starts(profile_type: Callable[[], Profile], setting: str) -> None: + # Hyprland used to enable seatd without installing it, and enabling a unit + # that is not there fails the installation. + profile = _profile_with(profile_type, setting) + + assert set(profile.services) <= set(profile.packages) + + +@pytest.mark.parametrize('profile_type', SEAT_PROFILES) +def test_a_profile_asks_for_nothing_until_a_choice_is_made(profile_type: Callable[[], Profile]) -> None: + profile = _profile_with(profile_type, None) + + assert profile.services == [] + assert 'seatd' not in profile.packages + + +def test_seatd_puts_the_users_in_the_seat_group() -> None: installer = FakeInstaller() users = [User('alice', Password(plaintext='pw'), False), User('bob', Password(plaintext='pw'), False)] - provision_seat_access(installer, users, setting) # type: ignore[arg-type] + provision_seat_access(installer, users, 'seatd') # type: ignore[arg-type] assert installer.commands == [ 'usermod -a -G seat alice', From f8e0f86bd676fef0b13b726f2f4e9f23bd01a56e Mon Sep 17 00:00:00 2001 From: VykosMolt Date: Tue, 8 Sep 2026 23:23:32 +0200 Subject: [PATCH 3/5] Trim redundant seat access comments --- .../default_profiles/desktops/utils.py | 22 ++----------------- tests/test_seat_access.py | 13 ----------- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/archinstall/default_profiles/desktops/utils.py b/archinstall/default_profiles/desktops/utils.py index f186a173ab..48969195a6 100644 --- a/archinstall/default_profiles/desktops/utils.py +++ b/archinstall/default_profiles/desktops/utils.py @@ -9,23 +9,15 @@ from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.result import ResultType -# Before this option was named after the seat manager it was labelled 'polkit', -# and saved configurations still contain that word. _LEGACY_LOGIND_SETTING = 'polkit' class SeatAccess(Enum): - """How a compositor is given access to the seat: the keyboard, mouse and - screen it is meant to drive. Arch offers two ways of doing that, and each - one needs different things installed and started.""" - Seatd = 'seatd' Logind = 'systemd-logind' @classmethod def from_setting(cls, value: str | None) -> SeatAccess | None: - """Read back what was stored in a profile's custom settings. Returns - None when nothing was chosen, or when the value is not one we know.""" if value is None: return None @@ -44,10 +36,7 @@ def packages(self) -> list[str]: case SeatAccess.Seatd: return ['seatd'] case SeatAccess.Logind: - # logind is part of systemd, so there is nothing to install for - # it. Arch ships polkit as an optional dependency of systemd, and - # logind checks with polkit before letting an unprivileged user - # act, so it has to be installed for this choice to work. + # logind uses polkit for unprivileged authorization. return ['polkit'] @property @@ -56,13 +45,11 @@ def services(self) -> list[str]: case SeatAccess.Seatd: return ['seatd'] case SeatAccess.Logind: - # logind is started on demand and cannot be enabled: its unit - # has no [Install] section. + # systemd-logind.service is static. return [] def seat_access_of(profile: Profile) -> SeatAccess | None: - """The seat access a profile was configured with, if any.""" return SeatAccess.from_setting(profile.custom_settings.get(CustomSetting.SeatAccess)) @@ -71,8 +58,6 @@ def provision_seat_access( users: list[User], seat_access: str, ) -> None: - # seatd decides who may talk to the hardware by group membership, so the - # people logging in have to be in it. logind needs nothing of the sort. if SeatAccess.from_setting(seat_access) is SeatAccess.Seatd: for user in users: install_session.arch_chroot(f'usermod -a -G seat {user.username}') @@ -86,9 +71,6 @@ async def select_seat_access(profile_name: str, default: str | None) -> SeatAcce items = [MenuItem(s.value, value=s) for s in SeatAccess] group = MenuItemGroup(items, sort_items=True) - # The menu items hold SeatAccess members while the saved setting is a plain - # string, so it has to be turned back into a member or nothing matches and - # the previous choice is not pre-selected. group.set_default_by_value(SeatAccess.from_setting(default)) result = await Selection[SeatAccess]( diff --git a/tests/test_seat_access.py b/tests/test_seat_access.py index dacb6bdeb1..b7bbaed8af 100644 --- a/tests/test_seat_access.py +++ b/tests/test_seat_access.py @@ -11,13 +11,10 @@ from archinstall.default_profiles.profile import CustomSetting, Profile from archinstall.lib.models.users import Password, User -# Every profile that asks the user how the compositor should reach the hardware. SEAT_PROFILES: list[Callable[[], Profile]] = [SwayProfile, HyprlandProfile, NiriProfile, LabwcProfile] class FakeInstaller: - """Records the commands provision_seat_access would run in the target.""" - def __init__(self) -> None: self.commands: list[str] = [] @@ -37,8 +34,6 @@ def test_saved_settings_are_read_back() -> None: def test_the_old_polkit_setting_still_means_logind() -> None: - # Configurations written before this option was renamed say 'polkit', and - # they have to keep working. assert SeatAccess.from_setting('polkit') is SeatAccess.Logind @@ -48,8 +43,6 @@ def test_nothing_chosen_and_nonsense_both_come_back_empty() -> None: def test_the_menu_offers_only_the_two_seat_managers() -> None: - # The menu is built by iterating the enum, so anything added to it shows up - # as an option. The old 'polkit' value must not reappear as one. assert [seat.value for seat in SeatAccess] == ['seatd', 'systemd-logind'] @@ -59,10 +52,6 @@ def test_seatd_is_installed_and_started() -> None: def test_logind_installs_polkit_and_starts_nothing() -> None: - # logind ships with systemd and its unit has no [Install] section, so there - # is nothing to install or enable for it. Arch ships polkit as an optional - # dependency of systemd, and logind checks with it before letting an - # unprivileged user act, so that has to be installed. assert SeatAccess.Logind.packages == ['polkit'] assert SeatAccess.Logind.services == [] @@ -70,8 +59,6 @@ def test_logind_installs_polkit_and_starts_nothing() -> None: @pytest.mark.parametrize('profile_type', SEAT_PROFILES) @pytest.mark.parametrize('setting', ['seatd', 'systemd-logind', 'polkit']) def test_a_profile_installs_whatever_it_starts(profile_type: Callable[[], Profile], setting: str) -> None: - # Hyprland used to enable seatd without installing it, and enabling a unit - # that is not there fails the installation. profile = _profile_with(profile_type, setting) assert set(profile.services) <= set(profile.packages) From 36175f3673d4d44af4a39256fd897cd4580b8a52 Mon Sep 17 00:00:00 2001 From: VykosMolt Date: Tue, 8 Sep 2026 23:41:30 +0200 Subject: [PATCH 4/5] Simplify seat access choices and restore menu focus --- .../default_profiles/desktops/hyprland.py | 13 +- .../default_profiles/desktops/labwc.py | 19 ++- archinstall/default_profiles/desktops/niri.py | 19 ++- archinstall/default_profiles/desktops/sway.py | 19 ++- .../default_profiles/desktops/utils.py | 55 ++------ tests/test_seat_access.py | 117 ++++++------------ 6 files changed, 76 insertions(+), 166 deletions(-) diff --git a/archinstall/default_profiles/desktops/hyprland.py b/archinstall/default_profiles/desktops/hyprland.py index c7bc9c325a..d2f8ab250d 100644 --- a/archinstall/default_profiles/desktops/hyprland.py +++ b/archinstall/default_profiles/desktops/hyprland.py @@ -1,6 +1,6 @@ from typing import override -from archinstall.default_profiles.desktops.utils import seat_access_of, select_seat_access +from archinstall.default_profiles.desktops.utils import SeatAccess, select_seat_access from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType @@ -18,7 +18,7 @@ def __init__(self) -> None: @property @override def packages(self) -> list[str]: - packages = [ + return [ 'hyprland', 'dunst', 'kitty', @@ -33,11 +33,6 @@ def packages(self) -> list[str]: 'slurp', ] - if seat := seat_access_of(self): - packages += seat.packages - - return packages - @property @override def default_greeter_type(self) -> GreeterType: @@ -46,8 +41,8 @@ def default_greeter_type(self) -> GreeterType: @property @override def services(self) -> list[str]: - if seat := seat_access_of(self): - return seat.services + if self.custom_settings.get(CustomSetting.SeatAccess) == SeatAccess.Seatd: + return ['seatd'] return [] @override diff --git a/archinstall/default_profiles/desktops/labwc.py b/archinstall/default_profiles/desktops/labwc.py index 72795eac6b..9d1710c930 100644 --- a/archinstall/default_profiles/desktops/labwc.py +++ b/archinstall/default_profiles/desktops/labwc.py @@ -1,6 +1,6 @@ from typing import override -from archinstall.default_profiles.desktops.utils import seat_access_of, select_seat_access +from archinstall.default_profiles.desktops.utils import SeatAccess, select_seat_access from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType @@ -18,15 +18,14 @@ def __init__(self) -> None: @property @override def packages(self) -> list[str]: - packages = [ + additional = [] + if seat := self.custom_settings.get(CustomSetting.SeatAccess, None): + additional = [seat] + + return [ 'alacritty', 'labwc', - ] - - if seat := seat_access_of(self): - packages += seat.packages - - return packages + ] + additional @property @override @@ -36,8 +35,8 @@ def default_greeter_type(self) -> GreeterType: @property @override def services(self) -> list[str]: - if seat := seat_access_of(self): - return seat.services + if self.custom_settings.get(CustomSetting.SeatAccess) == SeatAccess.Seatd: + return ['seatd'] return [] @override diff --git a/archinstall/default_profiles/desktops/niri.py b/archinstall/default_profiles/desktops/niri.py index db9f9b6d08..893202bb84 100644 --- a/archinstall/default_profiles/desktops/niri.py +++ b/archinstall/default_profiles/desktops/niri.py @@ -1,6 +1,6 @@ from typing import override -from archinstall.default_profiles.desktops.utils import seat_access_of, select_seat_access +from archinstall.default_profiles.desktops.utils import SeatAccess, select_seat_access from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType @@ -18,7 +18,11 @@ def __init__(self) -> None: @property @override def packages(self) -> list[str]: - packages = [ + additional = [] + if seat := self.custom_settings.get(CustomSetting.SeatAccess, None): + additional = [seat] + + return [ 'niri', 'alacritty', 'fuzzel', @@ -29,12 +33,7 @@ def packages(self) -> list[str]: 'swayidle', 'swaylock', 'xdg-desktop-portal-gnome', - ] - - if seat := seat_access_of(self): - packages += seat.packages - - return packages + ] + additional @property @override @@ -44,8 +43,8 @@ def default_greeter_type(self) -> GreeterType: @property @override def services(self) -> list[str]: - if seat := seat_access_of(self): - return seat.services + if self.custom_settings.get(CustomSetting.SeatAccess) == SeatAccess.Seatd: + return ['seatd'] return [] @override diff --git a/archinstall/default_profiles/desktops/sway.py b/archinstall/default_profiles/desktops/sway.py index 9156f845ad..9b50eadfe1 100644 --- a/archinstall/default_profiles/desktops/sway.py +++ b/archinstall/default_profiles/desktops/sway.py @@ -1,6 +1,6 @@ from typing import override -from archinstall.default_profiles.desktops.utils import seat_access_of, select_seat_access +from archinstall.default_profiles.desktops.utils import SeatAccess, select_seat_access from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType @@ -18,7 +18,11 @@ def __init__(self) -> None: @property @override def packages(self) -> list[str]: - packages = [ + additional = [] + if seat := self.custom_settings.get(CustomSetting.SeatAccess, None): + additional = [seat] + + return [ 'sway', 'swaybg', 'swaylock', @@ -31,12 +35,7 @@ def packages(self) -> list[str]: 'pavucontrol', 'foot', 'xorg-xwayland', - ] - - if seat := seat_access_of(self): - packages += seat.packages - - return packages + ] + additional @property @override @@ -46,8 +45,8 @@ def default_greeter_type(self) -> GreeterType: @property @override def services(self) -> list[str]: - if seat := seat_access_of(self): - return seat.services + if self.custom_settings.get(CustomSetting.SeatAccess) == SeatAccess.Seatd: + return ['seatd'] return [] @override diff --git a/archinstall/default_profiles/desktops/utils.py b/archinstall/default_profiles/desktops/utils.py index 48969195a6..d058486097 100644 --- a/archinstall/default_profiles/desktops/utils.py +++ b/archinstall/default_profiles/desktops/utils.py @@ -1,56 +1,16 @@ -from enum import Enum +from enum import StrEnum -from archinstall.default_profiles.profile import CustomSetting, Profile from archinstall.lib.installer import Installer -from archinstall.lib.log import warn from archinstall.lib.menu.helpers import Selection from archinstall.lib.models.users import User from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.result import ResultType -_LEGACY_LOGIND_SETTING = 'polkit' - -class SeatAccess(Enum): +class SeatAccess(StrEnum): Seatd = 'seatd' - Logind = 'systemd-logind' - - @classmethod - def from_setting(cls, value: str | None) -> SeatAccess | None: - if value is None: - return None - - if value == _LEGACY_LOGIND_SETTING: - return cls.Logind - - try: - return cls(value) - except ValueError: - warn(f'Unknown seat access setting, ignoring it: {value}') - return None - - @property - def packages(self) -> list[str]: - match self: - case SeatAccess.Seatd: - return ['seatd'] - case SeatAccess.Logind: - # logind uses polkit for unprivileged authorization. - return ['polkit'] - - @property - def services(self) -> list[str]: - match self: - case SeatAccess.Seatd: - return ['seatd'] - case SeatAccess.Logind: - # systemd-logind.service is static. - return [] - - -def seat_access_of(profile: Profile) -> SeatAccess | None: - return SeatAccess.from_setting(profile.custom_settings.get(CustomSetting.SeatAccess)) + Logind = 'polkit' # Keep the saved configuration value. def provision_seat_access( @@ -58,7 +18,7 @@ def provision_seat_access( users: list[User], seat_access: str, ) -> None: - if SeatAccess.from_setting(seat_access) is SeatAccess.Seatd: + if seat_access == SeatAccess.Seatd: for user in users: install_session.arch_chroot(f'usermod -a -G seat {user.username}') @@ -68,10 +28,13 @@ async def select_seat_access(profile_name: str, default: str | None) -> SeatAcce header += f' ({tr("collection of hardware devices i.e. keyboard, mouse")})' + '\n' header += tr('Choose an option how to give {} access to your hardware').format(profile_name) - items = [MenuItem(s.value, value=s) for s in SeatAccess] + items = [ + MenuItem('seatd', value=SeatAccess.Seatd), + MenuItem('systemd-logind', value=SeatAccess.Logind), + ] group = MenuItemGroup(items, sort_items=True) - group.set_default_by_value(SeatAccess.from_setting(default)) + group.set_focus_by_value(default) result = await Selection[SeatAccess]( group, diff --git a/tests/test_seat_access.py b/tests/test_seat_access.py index b7bbaed8af..5e3c64ce9b 100644 --- a/tests/test_seat_access.py +++ b/tests/test_seat_access.py @@ -1,5 +1,5 @@ +import asyncio from collections.abc import Callable -from typing import Any import pytest @@ -7,88 +7,43 @@ from archinstall.default_profiles.desktops.labwc import LabwcProfile from archinstall.default_profiles.desktops.niri import NiriProfile from archinstall.default_profiles.desktops.sway import SwayProfile -from archinstall.default_profiles.desktops.utils import SeatAccess, provision_seat_access +from archinstall.default_profiles.desktops.utils import SeatAccess from archinstall.default_profiles.profile import CustomSetting, Profile -from archinstall.lib.models.users import Password, User - -SEAT_PROFILES: list[Callable[[], Profile]] = [SwayProfile, HyprlandProfile, NiriProfile, LabwcProfile] - - -class FakeInstaller: - def __init__(self) -> None: - self.commands: list[str] = [] - - def arch_chroot(self, cmd: str, *args: Any, **kwargs: Any) -> None: - self.commands.append(cmd) +from archinstall.lib.menu.helpers import Selection +from archinstall.tui.result import Result + + +@pytest.mark.parametrize('profile_type', [HyprlandProfile, LabwcProfile, NiriProfile, SwayProfile]) +@pytest.mark.parametrize('default', [None, 'seatd', 'polkit']) +@pytest.mark.parametrize('choice', [None, 'seatd', 'polkit']) +def test_seat_access_selection( + monkeypatch: pytest.MonkeyPatch, + profile_type: Callable[[], Profile], + default: str | None, + choice: str | None, +) -> None: + async def show(selection: Selection[SeatAccess]) -> Result[SeatAccess]: + group = selection._group + assert [(item.text, item.get_value().value) for item in group.items] == [ + ('seatd', 'seatd'), + ('systemd-logind', 'polkit'), + ] + index = group.get_focused_index() + assert index == (1 if default == 'polkit' else 0) + if choice is None: + return Result[SeatAccess].selection(group.get_enabled_items()[index].get_value()) + return Result[SeatAccess].selection(next(item.get_value() for item in group.items if item.get_value().value == choice)) + + monkeypatch.setattr(Selection, 'show', show) + profile = profile_type() + profile.custom_settings[CustomSetting.SeatAccess] = default + asyncio.run(profile.do_on_select()) + assert profile.custom_settings[CustomSetting.SeatAccess] == (choice or default or 'seatd') -def _profile_with(profile_type: Callable[[], Profile], setting: str | None) -> Profile: +@pytest.mark.parametrize('profile_type', [HyprlandProfile, LabwcProfile, NiriProfile, SwayProfile]) +@pytest.mark.parametrize(('setting', 'services'), [(None, []), ('seatd', ['seatd']), ('polkit', [])]) +def test_seat_access_services(profile_type: Callable[[], Profile], setting: str | None, services: list[str]) -> None: profile = profile_type() profile.custom_settings[CustomSetting.SeatAccess] = setting - return profile - - -def test_saved_settings_are_read_back() -> None: - assert SeatAccess.from_setting('seatd') is SeatAccess.Seatd - assert SeatAccess.from_setting('systemd-logind') is SeatAccess.Logind - - -def test_the_old_polkit_setting_still_means_logind() -> None: - assert SeatAccess.from_setting('polkit') is SeatAccess.Logind - - -def test_nothing_chosen_and_nonsense_both_come_back_empty() -> None: - assert SeatAccess.from_setting(None) is None - assert SeatAccess.from_setting('not-a-seat-manager') is None - - -def test_the_menu_offers_only_the_two_seat_managers() -> None: - assert [seat.value for seat in SeatAccess] == ['seatd', 'systemd-logind'] - - -def test_seatd_is_installed_and_started() -> None: - assert SeatAccess.Seatd.packages == ['seatd'] - assert SeatAccess.Seatd.services == ['seatd'] - - -def test_logind_installs_polkit_and_starts_nothing() -> None: - assert SeatAccess.Logind.packages == ['polkit'] - assert SeatAccess.Logind.services == [] - - -@pytest.mark.parametrize('profile_type', SEAT_PROFILES) -@pytest.mark.parametrize('setting', ['seatd', 'systemd-logind', 'polkit']) -def test_a_profile_installs_whatever_it_starts(profile_type: Callable[[], Profile], setting: str) -> None: - profile = _profile_with(profile_type, setting) - - assert set(profile.services) <= set(profile.packages) - - -@pytest.mark.parametrize('profile_type', SEAT_PROFILES) -def test_a_profile_asks_for_nothing_until_a_choice_is_made(profile_type: Callable[[], Profile]) -> None: - profile = _profile_with(profile_type, None) - - assert profile.services == [] - assert 'seatd' not in profile.packages - - -def test_seatd_puts_the_users_in_the_seat_group() -> None: - installer = FakeInstaller() - users = [User('alice', Password(plaintext='pw'), False), User('bob', Password(plaintext='pw'), False)] - - provision_seat_access(installer, users, 'seatd') # type: ignore[arg-type] - - assert installer.commands == [ - 'usermod -a -G seat alice', - 'usermod -a -G seat bob', - ] - - -@pytest.mark.parametrize('setting', ['systemd-logind', 'polkit', None]) -def test_logind_needs_no_group_membership(setting: str | None) -> None: - installer = FakeInstaller() - users = [User('alice', Password(plaintext='pw'), False)] - - provision_seat_access(installer, users, setting) # type: ignore[arg-type] - - assert installer.commands == [] + assert profile.services == services From 038f0988cc19fa4cb887963c9bce564a8bc500b2 Mon Sep 17 00:00:00 2001 From: VykosMolt Date: Wed, 9 Sep 2026 09:33:30 +0200 Subject: [PATCH 5/5] Preserve the initial logind selection --- archinstall/default_profiles/desktops/utils.py | 2 +- tests/test_seat_access.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/archinstall/default_profiles/desktops/utils.py b/archinstall/default_profiles/desktops/utils.py index d058486097..496f0a1577 100644 --- a/archinstall/default_profiles/desktops/utils.py +++ b/archinstall/default_profiles/desktops/utils.py @@ -34,7 +34,7 @@ async def select_seat_access(profile_name: str, default: str | None) -> SeatAcce ] group = MenuItemGroup(items, sort_items=True) - group.set_focus_by_value(default) + group.set_focus_by_value(default or SeatAccess.Logind) result = await Selection[SeatAccess]( group, diff --git a/tests/test_seat_access.py b/tests/test_seat_access.py index 5e3c64ce9b..7484e72d1a 100644 --- a/tests/test_seat_access.py +++ b/tests/test_seat_access.py @@ -29,7 +29,7 @@ async def show(selection: Selection[SeatAccess]) -> Result[SeatAccess]: ('systemd-logind', 'polkit'), ] index = group.get_focused_index() - assert index == (1 if default == 'polkit' else 0) + assert index == (0 if default == 'seatd' else 1) if choice is None: return Result[SeatAccess].selection(group.get_enabled_items()[index].get_value()) return Result[SeatAccess].selection(next(item.get_value() for item in group.items if item.get_value().value == choice)) @@ -38,7 +38,7 @@ async def show(selection: Selection[SeatAccess]) -> Result[SeatAccess]: profile = profile_type() profile.custom_settings[CustomSetting.SeatAccess] = default asyncio.run(profile.do_on_select()) - assert profile.custom_settings[CustomSetting.SeatAccess] == (choice or default or 'seatd') + assert profile.custom_settings[CustomSetting.SeatAccess] == (choice or default or 'polkit') @pytest.mark.parametrize('profile_type', [HyprlandProfile, LabwcProfile, NiriProfile, SwayProfile])