Merge master into colored-install-preview

This commit is contained in:
Softer 2026-06-03 15:56:40 +03:00
commit a36b7e7d72
39 changed files with 954 additions and 255 deletions

View File

@ -41,8 +41,8 @@ body:
attributes:
value: >
**Note**: Assuming you have network connectivity,
you can easily post the installation log using the following command:
`curl -F'file=@/var/log/archinstall/install.log' https://0x0.st`
you can easily upload the installation log and get a shareable URL by running:
`archinstall share-log`
- type: textarea
id: freeform

View File

@ -31,7 +31,7 @@ repos:
args: [--config=.flake8]
fail_fast: true
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.20.2
rev: v2.1.0
hooks:
- id: mypy
args: [

View File

@ -101,9 +101,9 @@ If you come across any issues, kindly submit your issue here on GitHub or post y
When submitting an issue, please:
* Provide the stacktrace of the output if applicable
* Attach the `/var/log/archinstall/install.log` to the issue ticket. This helps us help you!
* To extract the log from the ISO image, one way is to use<br>
* To upload the log from the ISO image and get a shareable URL, run<br>
```shell
curl -F'file=@/var/log/archinstall/install.log' https://0x0.st
archinstall share-log
```

View File

@ -9,6 +9,7 @@ from archinstall.tui.result import ResultType
if TYPE_CHECKING:
from archinstall.lib.installer import Installer
from archinstall.lib.models.users import User
class DesktopProfile(Profile):
@ -88,6 +89,11 @@ class DesktopProfile(Profile):
for profile in self.current_selection:
profile.post_install(install_session)
@override
def provision(self, install_session: Installer, users: list[User]) -> None:
for profile in self.current_selection:
profile.provision(install_session, users)
@override
def install(self, install_session: Installer) -> None:
# Install common packages for all desktop environments

View File

@ -1,6 +0,0 @@
from enum import Enum
class SeatAccess(Enum):
seatd = 'seatd'
polkit = 'polkit'

View File

@ -1,6 +1,8 @@
from typing import override
from archinstall.default_profiles.profile import DisplayServerType, GreeterType, Profile, ProfileType
from archinstall.lib.installer import Installer
from archinstall.lib.models.users import User
class BspwmProfile(Profile):
@ -27,3 +29,11 @@ class BspwmProfile(Profile):
@override
def default_greeter_type(self) -> GreeterType:
return GreeterType.Lightdm
@override
def provision(self, install_session: Installer, users: list[User]) -> None:
for user in users:
install_session.arch_chroot('mkdir -p ~/.config/bspwm ~/.config/sxhkd', run_as=user.username)
install_session.arch_chroot('cp /usr/share/doc/bspwm/examples/bspwmrc ~/.config/bspwm/', run_as=user.username)
install_session.arch_chroot('cp /usr/share/doc/bspwm/examples/sxhkdrc ~/.config/sxhkd/', run_as=user.username)
install_session.arch_chroot('chmod +x ~/.config/bspwm/bspwmrc', run_as=user.username)

View File

@ -18,12 +18,13 @@ class BudgieProfile(Profile):
return [
'materia-gtk-theme',
'budgie',
'konsole',
'dolphin',
'mate-terminal',
'nemo',
'nemo-fileroller',
'papirus-icon-theme',
]
@property
@override
def default_greeter_type(self) -> GreeterType:
return GreeterType.Sddm
return GreeterType.LightdmSlick

View File

@ -1,11 +1,7 @@
from typing import override
from archinstall.default_profiles.desktops import SeatAccess
from archinstall.default_profiles.desktops.utils import select_seat_access
from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType
from archinstall.lib.menu.helpers import Selection
from archinstall.lib.translationhandler import tr
from archinstall.tui.menu_item import MenuItem, MenuItemGroup
from archinstall.tui.result import ResultType
class HyprlandProfile(Profile):
@ -49,26 +45,8 @@ class HyprlandProfile(Profile):
return [pref]
return []
async def _select_seat_access(self) -> None:
# need to activate seat service and add to seat group
header = tr('Hyprland needs access to your seat (collection of hardware devices i.e. keyboard, mouse, etc)')
header += '\n' + tr('Choose an option to give Hyprland access to your hardware') + '\n'
items = [MenuItem(s.value, value=s) for s in SeatAccess]
group = MenuItemGroup(items, sort_items=True)
default = self.custom_settings.get(CustomSetting.SeatAccess, None)
group.set_default_by_value(default)
result = await Selection[SeatAccess](
group,
header=header,
allow_skip=False,
).show()
if result.type_ == ResultType.Selection:
self.custom_settings[CustomSetting.SeatAccess] = result.get_value().value
@override
async def do_on_select(self) -> None:
await self._select_seat_access()
default = self.custom_settings.get(CustomSetting.SeatAccess, None)
seat_access = await select_seat_access(self.name, default)
self.custom_settings[CustomSetting.SeatAccess] = seat_access.value

View File

@ -1,11 +1,7 @@
from typing import override
from archinstall.default_profiles.desktops import SeatAccess
from archinstall.default_profiles.desktops.utils import select_seat_access
from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType
from archinstall.lib.menu.helpers import Selection
from archinstall.lib.translationhandler import tr
from archinstall.tui.menu_item import MenuItem, MenuItemGroup
from archinstall.tui.result import ResultType
class LabwcProfile(Profile):
@ -43,26 +39,8 @@ class LabwcProfile(Profile):
return [pref]
return []
async def _select_seat_access(self) -> None:
# need to activate seat service and add to seat group
header = tr('labwc needs access to your seat (collection of hardware devices i.e. keyboard, mouse, etc)')
header += '\n' + tr('Choose an option to give labwc access to your hardware') + '\n'
items = [MenuItem(s.value, value=s) for s in SeatAccess]
group = MenuItemGroup(items, sort_items=True)
default = self.custom_settings.get(CustomSetting.SeatAccess, None)
group.set_default_by_value(default)
result = await Selection[SeatAccess](
group,
header=header,
allow_skip=False,
).show()
if result.type_ == ResultType.Selection:
self.custom_settings[CustomSetting.SeatAccess] = result.get_value().value
@override
async def do_on_select(self) -> None:
await self._select_seat_access()
default = self.custom_settings.get(CustomSetting.SeatAccess, None)
seat_access = await select_seat_access(self.name, default)
self.custom_settings[CustomSetting.SeatAccess] = seat_access.value

View File

@ -1,11 +1,7 @@
from typing import override
from archinstall.default_profiles.desktops import SeatAccess
from archinstall.default_profiles.desktops.utils import select_seat_access
from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType
from archinstall.lib.menu.helpers import Selection
from archinstall.lib.translationhandler import tr
from archinstall.tui.menu_item import MenuItem, MenuItemGroup
from archinstall.tui.result import ResultType
class NiriProfile(Profile):
@ -51,26 +47,8 @@ class NiriProfile(Profile):
return [pref]
return []
async def _select_seat_access(self) -> None:
# need to activate seat service and add to seat group
header = tr('niri needs access to your seat (collection of hardware devices i.e. keyboard, mouse, etc)')
header += '\n' + tr('Choose an option to give niri access to your hardware') + '\n'
items = [MenuItem(s.value, value=s) for s in SeatAccess]
group = MenuItemGroup(items, sort_items=True)
default = self.custom_settings.get(CustomSetting.SeatAccess, None)
group.set_default_by_value(default)
result = await Selection[SeatAccess](
group,
header=header,
allow_skip=False,
).show()
if result.type_ == ResultType.Selection:
self.custom_settings[CustomSetting.SeatAccess] = result.get_value().value
@override
async def do_on_select(self) -> None:
await self._select_seat_access()
default = self.custom_settings.get(CustomSetting.SeatAccess, None)
seat_access = await select_seat_access(self.name, default)
self.custom_settings[CustomSetting.SeatAccess] = seat_access.value

View File

@ -1,11 +1,7 @@
from typing import override
from archinstall.default_profiles.desktops import SeatAccess
from archinstall.default_profiles.desktops.utils import select_seat_access
from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType
from archinstall.lib.menu.helpers import Selection
from archinstall.lib.translationhandler import tr
from archinstall.tui.menu_item import MenuItem, MenuItemGroup
from archinstall.tui.result import ResultType
class SwayProfile(Profile):
@ -53,26 +49,8 @@ class SwayProfile(Profile):
return [pref]
return []
async def _select_seat_access(self) -> None:
# need to activate seat service and add to seat group
header = tr('Sway needs access to your seat (collection of hardware devices i.e. keyboard, mouse, etc)')
header += '\n' + tr('Choose an option to give Sway access to your hardware') + '\n'
items = [MenuItem(s.value, value=s) for s in SeatAccess]
group = MenuItemGroup(items, sort_items=True)
default = self.custom_settings.get(CustomSetting.SeatAccess, None)
group.set_default_by_value(default)
result = await Selection[SeatAccess](
group,
header=header,
allow_skip=False,
).show()
if result.type_ == ResultType.Selection:
self.custom_settings[CustomSetting.SeatAccess] = result.get_value().value
@override
async def do_on_select(self) -> None:
await self._select_seat_access()
default = self.custom_settings.get(CustomSetting.SeatAccess, None)
seat_access = await select_seat_access(self.name, default)
self.custom_settings[CustomSetting.SeatAccess] = seat_access.value

View File

@ -0,0 +1,33 @@
from enum import Enum
from archinstall.lib.menu.helpers import Selection
from archinstall.lib.translationhandler import tr
from archinstall.tui.menu_item import MenuItem, MenuItemGroup
from archinstall.tui.result import ResultType
class SeatAccess(Enum):
seatd = 'seatd'
polkit = 'polkit'
async def select_seat_access(profile_name: str, default: str | None) -> SeatAccess:
header = tr('{} needs access to your seat').format(profile_name)
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]
group = MenuItemGroup(items, sort_items=True)
group.set_default_by_value(default)
result = await Selection[SeatAccess](
group,
header=header,
allow_skip=False,
).show()
if result.type_ == ResultType.Selection:
return result.get_value()
else:
raise ValueError('Unexpected result type from seat access selection')

View File

@ -6,6 +6,7 @@ import urllib.error
import urllib.parse
from argparse import ArgumentParser, Namespace
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Any, Self
from urllib.request import Request, urlopen
@ -17,6 +18,7 @@ from archinstall.lib.menu.util import get_password
from archinstall.lib.models.application import ApplicationConfiguration, ZramConfiguration
from archinstall.lib.models.authentication import AuthenticationConfiguration
from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration
from archinstall.lib.models.config import SubConfig
from archinstall.lib.models.device import DiskEncryption, DiskLayoutConfiguration
from archinstall.lib.models.locale import LocaleConfiguration
from archinstall.lib.models.mirrors import MirrorConfiguration
@ -57,6 +59,81 @@ class Arguments:
verbose: bool = False
class ArchConfigType(StrEnum):
VERSION = 'version'
SCRIPT = 'script'
LOCALE_CONFIG = 'locale_config'
ARCHINSTALL_LANGUAGE = 'archinstall_language'
DISK_CONFIG = 'disk_config'
PROFILE_CONFIG = 'profile_config'
MIRROR_CONFIG = 'mirror_config'
NETWORK_CONFIG = 'network_config'
BOOTLOADER_CONFIG = 'bootloader_config'
APP_CONFIG = 'app_config'
AUTH_CONFIG = 'auth_config'
SWAP = 'swap'
USERS = 'users'
ROOT_ENC_PASSWORD = 'root_enc_password'
ENCRYPTION_PASSWORD = 'encryption_password'
HOSTNAME = 'hostname'
KERNELS = 'kernels'
NTP = 'ntp'
TIMEZONE = 'timezone'
SERVICES = 'services'
PACKAGES = 'packages'
PACMAN_CONFIG = 'pacman_config'
CUSTOM_COMMANDS = 'custom_commands'
def text(self) -> str:
match self:
case ArchConfigType.ARCHINSTALL_LANGUAGE:
return tr('ArchInstall Language')
case ArchConfigType.VERSION:
return tr('Version')
case ArchConfigType.SCRIPT:
return tr('Installation Script')
case ArchConfigType.LOCALE_CONFIG:
return tr('Locales')
case ArchConfigType.DISK_CONFIG:
return tr('Disk configuration')
case ArchConfigType.PROFILE_CONFIG:
return tr('Profile')
case ArchConfigType.MIRROR_CONFIG:
return tr('Mirrors and repositories')
case ArchConfigType.NETWORK_CONFIG:
return tr('Network')
case ArchConfigType.BOOTLOADER_CONFIG:
return tr('Bootloader')
case ArchConfigType.APP_CONFIG:
return tr('Application')
case ArchConfigType.AUTH_CONFIG:
return tr('Authentication')
case ArchConfigType.SWAP:
return tr('Swap')
case ArchConfigType.HOSTNAME:
return tr('Hostname')
case ArchConfigType.KERNELS:
return tr('Kernels')
case ArchConfigType.NTP:
return tr('Automatic time sync (NTP)')
case ArchConfigType.TIMEZONE:
return tr('Timezone')
case ArchConfigType.SERVICES:
return tr('Services')
case ArchConfigType.PACKAGES:
return tr('Additional packages')
case ArchConfigType.PACMAN_CONFIG:
return tr('Pacman')
case ArchConfigType.CUSTOM_COMMANDS:
return tr('Custom commands')
case ArchConfigType.USERS:
return tr('Users')
case ArchConfigType.ROOT_ENC_PASSWORD:
return tr('Root encrypted password')
case ArchConfigType.ENCRYPTION_PASSWORD:
return tr('Disk encryption password')
@dataclass
class ArchConfig:
version: str | None = None
@ -80,58 +157,84 @@ class ArchConfig:
services: list[str] = field(default_factory=list)
custom_commands: list[str] = field(default_factory=list)
def unsafe_config(self) -> dict[str, Any]:
config: dict[str, list[UserSerialization] | str | None] = {}
def unsafe_config(self) -> dict[ArchConfigType, Any]:
config: dict[ArchConfigType, list[UserSerialization] | str | None] = {}
if self.auth_config:
if self.auth_config.users:
config['users'] = [user.json() for user in self.auth_config.users]
config[ArchConfigType.USERS] = [user.json() for user in self.auth_config.users]
if self.auth_config.root_enc_password:
config['root_enc_password'] = self.auth_config.root_enc_password.enc_password
config[ArchConfigType.ROOT_ENC_PASSWORD] = self.auth_config.root_enc_password.enc_password
if self.disk_config:
disk_encryption = self.disk_config.disk_encryption
if disk_encryption and disk_encryption.encryption_password:
config['encryption_password'] = disk_encryption.encryption_password.plaintext
config[ArchConfigType.ENCRYPTION_PASSWORD] = disk_encryption.encryption_password.plaintext
return config
def safe_config(self) -> dict[str, Any]:
config: Any = {
'version': self.version,
'script': self.script,
'archinstall-language': self.archinstall_language.json(),
'hostname': self.hostname,
'kernels': self.kernels,
'ntp': self.ntp,
'packages': self.packages,
'pacman_config': self.pacman_config.json(),
'swap': self.swap,
'timezone': self.timezone,
'services': self.services,
'custom_commands': self.custom_commands,
'bootloader_config': self.bootloader_config.json() if self.bootloader_config else None,
'app_config': self.app_config.json() if self.app_config else None,
'auth_config': self.auth_config.json() if self.auth_config else None,
def safe_config(self) -> dict[ArchConfigType, Any]:
base_config: dict[ArchConfigType, Any] = {
ArchConfigType.VERSION: self.version,
ArchConfigType.SCRIPT: self.script,
ArchConfigType.ARCHINSTALL_LANGUAGE: self.archinstall_language.json(),
}
if self.locale_config:
config['locale_config'] = self.locale_config.json()
base_config.update(self.plain_cfg())
sub_config = self.sub_cfg()
if self.disk_config:
config['disk_config'] = self.disk_config.json()
for config_type, value in sub_config.items():
if not hasattr(value, 'json'):
raise ValueError(f'Config value for {config_type} must implement json() method')
base_config[config_type] = value.json()
if self.profile_config:
config['profile_config'] = self.profile_config.json()
return base_config
def plain_cfg(self) -> dict[ArchConfigType, str | list[str] | bool]:
return {
ArchConfigType.HOSTNAME: self.hostname,
ArchConfigType.KERNELS: self.kernels,
ArchConfigType.NTP: self.ntp,
ArchConfigType.TIMEZONE: self.timezone,
ArchConfigType.SERVICES: self.services,
ArchConfigType.PACKAGES: self.packages,
ArchConfigType.CUSTOM_COMMANDS: self.custom_commands,
}
def sub_cfg(self) -> dict[ArchConfigType, SubConfig]:
cfg: dict[ArchConfigType, SubConfig] = {
ArchConfigType.PACMAN_CONFIG: self.pacman_config,
}
if self.mirror_config:
config['mirror_config'] = self.mirror_config.json()
cfg[ArchConfigType.MIRROR_CONFIG] = self.mirror_config
if self.bootloader_config:
cfg[ArchConfigType.BOOTLOADER_CONFIG] = self.bootloader_config
if self.disk_config:
cfg[ArchConfigType.DISK_CONFIG] = self.disk_config
if self.swap:
cfg[ArchConfigType.SWAP] = self.swap
if self.auth_config:
cfg[ArchConfigType.AUTH_CONFIG] = self.auth_config
if self.locale_config:
cfg[ArchConfigType.LOCALE_CONFIG] = self.locale_config
if self.profile_config:
cfg[ArchConfigType.PROFILE_CONFIG] = self.profile_config
if self.network_config:
config['network_config'] = self.network_config.json()
cfg[ArchConfigType.NETWORK_CONFIG] = self.network_config
return config
if self.app_config:
cfg[ArchConfigType.APP_CONFIG] = self.app_config
return cfg
@classmethod
def from_config(cls, args_config: dict[str, Any], args: Arguments) -> Self:
@ -431,7 +534,6 @@ class ArchConfigHandler:
default=False,
help='Enabled verbose options',
)
return parser
def _parse_args(self) -> Arguments:

View File

@ -6,13 +6,12 @@ from typing import Any
from pydantic import TypeAdapter
from archinstall.lib.args import ArchConfig
from archinstall.lib.args import ArchConfig, ArchConfigType
from archinstall.lib.crypt import encrypt
from archinstall.lib.menu.helpers import Confirmation, Selection
from archinstall.lib.menu.util import get_password, prompt_dir
from archinstall.lib.models.bootloader import Bootloader
from archinstall.lib.models.network import NetworkConfiguration
from archinstall.lib.output import debug, logger, warn
from archinstall.lib.output import as_key_value_pair, debug, logger, warn
from archinstall.lib.translationhandler import tr
from archinstall.tui.menu_item import MenuItem, MenuItemGroup
from archinstall.tui.result import ResultType
@ -45,15 +44,15 @@ class ConfigurationOutput:
def user_config_to_json(self) -> str:
config = self._config.safe_config()
adapter = TypeAdapter(dict[str, Any])
adapter = TypeAdapter(dict[ArchConfigType, Any])
python_dict = adapter.dump_python(config)
return json.dumps(python_dict, indent=4, sort_keys=True)
def user_credentials_to_json(self) -> str:
config = self._config.unsafe_config()
cfg = self._config.unsafe_config()
adapter = TypeAdapter(dict[str, Any])
python_dict = adapter.dump_python(config)
adapter = TypeAdapter(dict[ArchConfigType, Any])
python_dict = adapter.dump_python(cfg)
return json.dumps(python_dict, indent=4, sort_keys=True)
def write_debug(self) -> None:
@ -64,65 +63,24 @@ class ConfigurationOutput:
"""
Render a concise two-column summary of the current configuration.
The left column holds section labels, the right column holds values.
Column width adapts to the longest translated label so translations
do not break the alignment. Rows whose underlying config is not set
are skipped.
Returns an empty string if nothing meaningful to show.
"""
rows: list[tuple[str, str]] = []
cfg: dict[str, str | list[str] | bool] = {}
disk_config = self._config.disk_config
if disk_config and disk_config.device_modifications:
disk_parts: list[str] = []
for mod in disk_config.device_modifications:
path = str(mod.device_path)
root_part = mod.get_root_partition()
flags: list[str] = []
if root_part and root_part.fs_type:
flags.append(root_part.fs_type.value)
if disk_config.disk_encryption:
flags.append(tr('LUKS'))
disk_parts.append(f'{path} ({" + ".join(flags)})' if flags else path)
rows.append((tr('Disks'), ', '.join(disk_parts)))
for key, value in self._config.plain_cfg().items():
cfg[key.text()] = value
bl_config = self._config.bootloader_config
if bl_config and bl_config.bootloader != Bootloader.NO_BOOTLOADER:
rows.append((tr('Bootloader'), bl_config.bootloader.value))
for config_type, obj in self._config.sub_cfg().items():
if not hasattr(obj, 'summary'):
continue
kernels = self._config.kernels
if kernels:
rows.append((tr('Kernel'), ', '.join(kernels)))
summary = obj.summary()
if summary:
cfg[config_type.text()] = summary
profile_config = self._config.profile_config
if profile_config and profile_config.profile:
names = profile_config.profile.current_selection_names()
rows.append((tr('Profile'), ', '.join(names) if names else profile_config.profile.name))
if profile_config.greeter:
rows.append((tr('Greeter'), profile_config.greeter.value))
simple_summary = as_key_value_pair(cfg, ignore_empty=True)
packages = self._config.packages
if packages:
rows.append((tr('Packages'), str(len(packages))))
net_config = self._config.network_config
if isinstance(net_config, NetworkConfiguration):
rows.append((tr('Network'), net_config.type.display_msg()))
locale_config = self._config.locale_config
if locale_config:
rows.append((tr('Locale'), locale_config.sys_lang))
tz = self._config.timezone
if tz:
rows.append((tr('Timezone'), tz))
if not rows:
return ''
label_width = max(len(label) for label, _ in rows) + 2
return '\n'.join(f'{label:<{label_width}}{value}' for label, value in rows)
return simple_summary
async def confirm_config(self) -> bool:
header = f'{tr("The specified configuration will be applied")}. '

View File

@ -1754,6 +1754,7 @@ class Installer:
self,
root: PartitionModification | LvmVolume,
efi_partition: PartitionModification | None,
keep_initramfs: bool = False,
) -> None:
if not efi_partition or not efi_partition.mountpoint:
raise ValueError(f'Could not detect ESP at mountpoint {self.target}')
@ -1777,11 +1778,11 @@ class Installer:
config = preset.read_text().splitlines(True)
for index, line in enumerate(config):
# Avoid storing redundant image file
if m := image_re.match(line):
image = self.target / m.group(2)
image.unlink(missing_ok=True)
config[index] = '#' + m.group(1)
if not keep_initramfs:
image = self.target / m.group(2)
image.unlink(missing_ok=True)
config[index] = '#' + m.group(1)
elif m := uki_re.match(line):
if diff_mountpoint:
config[index] = m.group(2) + diff_mountpoint + m.group(3)
@ -1800,7 +1801,12 @@ class Installer:
if not self.mkinitcpio(['-P']):
error('Error generating initramfs (continuing anyway)')
def add_bootloader(self, bootloader: Bootloader, uki_enabled: bool = False, bootloader_removable: bool = False) -> None:
def add_bootloader(
self,
bootloader: Bootloader,
uki_enabled: bool = False,
bootloader_removable: bool = False,
) -> None:
"""
Adds a bootloader to the installation instance.
Archinstall supports one of five types:
@ -1849,7 +1855,13 @@ class Installer:
bootloader_removable = False
if uki_enabled:
self._config_uki(root, efi_partition)
keep_initramfs = (
bootloader == Bootloader.Grub
and self._disk_config.has_default_btrfs_vols()
and self._disk_config.btrfs_options is not None
and self._disk_config.btrfs_options.snapshot_config is not None
)
self._config_uki(root, efi_partition, keep_initramfs)
match bootloader:
case Bootloader.Systemd:

View File

@ -20,6 +20,7 @@ class Selection[ValueT]:
preview_location: Literal['right', 'bottom'] | None = None,
multi: bool = False,
enable_filter: bool = False,
wrap_preview: bool = False,
):
self._header = header
self._title = title
@ -29,6 +30,7 @@ class Selection[ValueT]:
self._preview_location = preview_location
self._multi = multi
self._enable_filter = enable_filter
self._wrap_preview = wrap_preview
async def show(self) -> Result[ValueT]:
if self._multi:
@ -39,6 +41,7 @@ class Selection[ValueT]:
allow_reset=self._allow_reset,
preview_location=self._preview_location,
enable_filter=self._enable_filter,
wrap_preview=self._wrap_preview,
).run()
else:
result = await OptionListScreen[ValueT](
@ -49,6 +52,7 @@ class Selection[ValueT]:
allow_reset=self._allow_reset,
preview_location=self._preview_location,
enable_filter=self._enable_filter,
wrap_preview=self._wrap_preview,
).run()
if result.type_ == ResultType.Reset:

View File

@ -1,7 +1,8 @@
from dataclasses import dataclass
from enum import StrEnum, auto
from typing import Any, NotRequired, Self, TypedDict
from typing import Any, NotRequired, Self, TypedDict, override
from archinstall.lib.models.config import SubConfig
from archinstall.lib.translationhandler import tr
@ -168,7 +169,7 @@ class FontsConfiguration:
@dataclass(frozen=True)
class ZramConfiguration:
class ZramConfiguration(SubConfig):
enabled: bool
algorithm: ZramAlgorithm = ZramAlgorithm.ZSTD
@ -181,9 +182,28 @@ class ZramConfiguration:
algo = arg.get('algorithm', arg.get('algo', ZramAlgorithm.ZSTD.value))
return cls(enabled=enabled, algorithm=ZramAlgorithm(algo))
@override
def json(self) -> dict[str, bool | str]:
return {
'enabled': self.enabled,
'algorithm': self.algorithm.value,
}
@override
def summary(self) -> list[str] | None:
out: list[str] = []
if self.enabled:
out.append(tr('Zram enabled'))
out.append(tr('Zram algorithm {}').format(self.algorithm))
return out
return None
@dataclass
class ApplicationConfiguration:
class ApplicationConfiguration(SubConfig):
bluetooth_config: BluetoothConfiguration | None = None
audio_config: AudioConfiguration | None = None
power_management_config: PowerManagementConfiguration | None = None
@ -223,6 +243,7 @@ class ApplicationConfiguration:
return app_config
@override
def json(self) -> ApplicationSerialization:
config: ApplicationSerialization = {}
@ -245,3 +266,28 @@ class ApplicationConfiguration:
config['fonts_config'] = self.fonts_config.json()
return config
@override
def summary(self) -> list[str]:
out: list[str] = []
if self.bluetooth_config and self.bluetooth_config.enabled:
out.append(tr('Bluetooth enabled'))
if self.audio_config:
out.append(tr('Audio server "{}"').format(self.audio_config.audio))
if self.power_management_config:
out.append(tr('Power management "{}"').format(self.power_management_config.power_management))
if self.print_service_config and self.print_service_config.enabled:
out.append(tr('Print service enabled'))
if self.firewall_config:
out.append(tr('Firewall "{}"').format(self.firewall_config.firewall))
if self.fonts_config and self.fonts_config.fonts:
fonts = ', '.join(f.value for f in self.fonts_config.fonts)
out.append(tr('Extra fonts "{}"').format(fonts))
return out

View File

@ -1,7 +1,8 @@
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, NotRequired, Self, TypedDict
from typing import Any, NotRequired, Self, TypedDict, override
from archinstall.lib.models.config import SubConfig
from archinstall.lib.models.users import Password, User
from archinstall.lib.translationhandler import tr
@ -58,7 +59,7 @@ class U2FLoginConfiguration:
@dataclass
class AuthenticationConfiguration:
class AuthenticationConfiguration(SubConfig):
root_enc_password: Password | None = None
users: list[User] = field(default_factory=list)
u2f_config: U2FLoginConfiguration | None = None
@ -75,6 +76,7 @@ class AuthenticationConfiguration:
return auth_config
@override
def json(self) -> AuthenticationSerialization:
config: AuthenticationSerialization = {}
@ -83,6 +85,21 @@ class AuthenticationConfiguration:
return config
@override
def summary(self) -> list[str]:
out: list[str] = []
if self.root_enc_password:
out.append(tr('Root password set'))
if self.users:
out.append(tr('Configured {} user(s)').format(len(self.users)))
if self.u2f_config:
out.append(tr('U2F set up'))
return out
def has_superuser(self) -> bool:
return any(u.sudo for u in self.users)

View File

@ -1,8 +1,9 @@
import sys
from dataclasses import dataclass
from enum import Enum
from typing import Any, Self
from typing import Any, Self, override
from archinstall.lib.models.config import SubConfig
from archinstall.lib.output import warn
from archinstall.lib.translationhandler import tr
@ -60,14 +61,26 @@ class Bootloader(Enum):
@dataclass
class BootloaderConfiguration:
class BootloaderConfiguration(SubConfig):
bootloader: Bootloader
uki: bool = False
removable: bool = True
@override
def json(self) -> dict[str, Any]:
return {'bootloader': self.bootloader.json(), 'uki': self.uki, 'removable': self.removable}
@override
def summary(self) -> list[str]:
out = [tr('Bootloader "{}"').format(self.bootloader.value)]
if self.uki:
out.append(tr('UKI enabled'))
if self.removable:
out.append(tr('Removable'))
return out
@classmethod
def parse_arg(cls, config: dict[str, Any], skip_boot: bool) -> Self:
bootloader = Bootloader.from_arg(config.get('bootloader', ''), skip_boot)

View File

@ -0,0 +1,12 @@
from abc import ABC, abstractmethod
from typing import Any
class SubConfig(ABC):
@abstractmethod
def json(self) -> Any:
pass
@abstractmethod
def summary(self) -> str | list[str] | None:
pass

View File

@ -12,6 +12,7 @@ from parted import Disk, Geometry, Partition
from pydantic import BaseModel, Field, ValidationInfo, field_serializer, field_validator
from archinstall.lib.hardware import SysInfo
from archinstall.lib.models.config import SubConfig
from archinstall.lib.models.users import Password
from archinstall.lib.output import debug
from archinstall.lib.translationhandler import tr
@ -34,6 +35,15 @@ class DiskLayoutType(Enum):
case DiskLayoutType.Pre_mount:
return tr('Pre-mounted configuration')
def short_msg(self) -> str:
match self:
case DiskLayoutType.Default:
return tr('Default')
case DiskLayoutType.Manual:
return tr('Manual')
case DiskLayoutType.Pre_mount:
return tr('Pre-mount')
class _DiskLayoutConfigurationSerialization(TypedDict):
config_type: str
@ -45,7 +55,7 @@ class _DiskLayoutConfigurationSerialization(TypedDict):
@dataclass
class DiskLayoutConfiguration:
class DiskLayoutConfiguration(SubConfig):
config_type: DiskLayoutType
device_modifications: list[DeviceModification] = field(default_factory=list)
lvm_config: LvmConfiguration | None = None
@ -55,6 +65,7 @@ class DiskLayoutConfiguration:
# used for pre-mounted config
mountpoint: Path | None = None
@override
def json(self) -> _DiskLayoutConfigurationSerialization:
if self.config_type == DiskLayoutType.Pre_mount:
return {
@ -78,6 +89,28 @@ class DiskLayoutConfiguration:
return config
@override
def summary(self) -> list[str]:
out = [tr('{} layout').format(self.config_type.short_msg())]
devices = set(mod.device_path for mod in self.device_modifications)
if devices:
dev_str = ', '.join(str(d) for d in devices)
out.append(tr('Devices {}').format(dev_str))
if self.lvm_config is not None:
out.append(tr('LVM set up'))
if self.disk_encryption is not None:
out.append(tr('{} encryption').format(self.disk_encryption.encryption_type.type_to_text()))
if self.btrfs_options is not None:
if self.btrfs_options.snapshot_config:
out.append(tr('Btrfs snapshot "{}"').format(self.btrfs_options.snapshot_config.snapshot_type))
return out
@classmethod
def parse_arg(
cls,
@ -1321,7 +1354,7 @@ class _SnapshotConfigSerialization(TypedDict):
type: str
class SnapshotType(Enum):
class SnapshotType(StrEnum):
Snapper = 'Snapper'
Timeshift = 'Timeshift'

View File

@ -1,12 +1,13 @@
from dataclasses import dataclass
from typing import Any, Self
from typing import Any, Self, override
from archinstall.lib.locale.utils import get_kb_layout
from archinstall.lib.models.config import SubConfig
from archinstall.lib.translationhandler import tr
@dataclass
class LocaleConfiguration:
class LocaleConfiguration(SubConfig):
kb_layout: str
sys_lang: str
sys_enc: str
@ -23,6 +24,7 @@ class LocaleConfiguration:
layout = 'us'
return cls(layout, 'en_US.UTF-8', 'UTF-8')
@override
def json(self) -> dict[str, str]:
return {
'kb_layout': self.kb_layout,
@ -31,6 +33,15 @@ class LocaleConfiguration:
'console_font': self.console_font,
}
@override
def summary(self) -> list[str]:
return [
tr('Keyboard layout "{}"').format(self.kb_layout),
tr('Locale language "{}"').format(self.sys_lang),
tr('Locale encoding "{}"').format(self.sys_enc),
tr('Console font "{}"').format(self.console_font),
]
def preview(self) -> str:
output = '{}: {}\n'.format(tr('Keyboard layout'), self.kb_layout)
output += '{}: {}\n'.format(tr('Locale language'), self.sys_lang)

View File

@ -9,9 +9,11 @@ from typing import TYPE_CHECKING, Any, Self, TypedDict, override
from pydantic import BaseModel, ValidationInfo, field_validator, model_validator
from archinstall.lib.models.config import SubConfig
from archinstall.lib.models.packages import Repository
from archinstall.lib.networking import DownloadTimer, ping
from archinstall.lib.output import debug
from archinstall.lib.translationhandler import tr
if TYPE_CHECKING:
from archinstall.lib.mirror.mirror_handler import MirrorListHandler
@ -236,7 +238,7 @@ class _MirrorConfigurationSerialization(TypedDict):
@dataclass
class MirrorConfiguration:
class MirrorConfiguration(SubConfig):
mirror_regions: list[MirrorRegion] = field(default_factory=list)
custom_servers: list[CustomServer] = field(default_factory=list)
optional_repositories: list[Repository] = field(default_factory=list)
@ -250,6 +252,7 @@ class MirrorConfiguration:
def custom_server_urls(self) -> str:
return '\n'.join(s.url for s in self.custom_servers)
@override
def json(self) -> _MirrorConfigurationSerialization:
regions = {}
for m in self.mirror_regions:
@ -262,6 +265,24 @@ class MirrorConfiguration:
'custom_repositories': [c.json() for c in self.custom_repositories],
}
@override
def summary(self) -> list[str]:
out: list[str] = []
if self.mirror_regions:
out.append(tr('Mirror regions "{}"').format(', '.join(m.name for m in self.mirror_regions)))
if self.optional_repositories:
out.append(tr('Optional repositories "{}"').format(', '.join(r.value for r in self.optional_repositories)))
if self.custom_servers:
out.append(tr('Custom servers set up'))
if self.custom_repositories:
out.append(tr('Custom repositories set up'))
return out
def custom_servers_config(self) -> str:
config = ''

View File

@ -3,6 +3,7 @@ from dataclasses import dataclass, field
from enum import Enum
from typing import NotRequired, Self, TypedDict, override
from archinstall.lib.models.config import SubConfig
from archinstall.lib.output import debug
from archinstall.lib.translationhandler import tr
@ -103,10 +104,11 @@ class _NetworkConfigurationSerialization(TypedDict):
@dataclass
class NetworkConfiguration:
class NetworkConfiguration(SubConfig):
type: NicType
nics: list[Nic] = field(default_factory=list)
@override
def json(self) -> _NetworkConfigurationSerialization:
config: _NetworkConfigurationSerialization = {'type': self.type.value}
if self.nics:
@ -114,6 +116,10 @@ class NetworkConfiguration:
return config
@override
def summary(self) -> str:
return self.type.display_msg()
@classmethod
def parse_arg(cls, config: _NetworkConfigurationSerialization) -> Self | None:
nic_type = config.get('type', None)

View File

@ -132,7 +132,6 @@ class AvailablePackage(BaseModel):
def longest_key(self) -> int:
return max(len(key) for key in self.model_dump().keys())
# return all package info line by line
def info(self) -> str:
output = ''
for key, value in self.model_dump().items():

View File

@ -1,6 +1,7 @@
from dataclasses import dataclass
from typing import Self, TypedDict
from typing import Self, TypedDict, override
from archinstall.lib.models.config import SubConfig
from archinstall.lib.translationhandler import tr
@ -10,7 +11,7 @@ class PacmanConfigSerialization(TypedDict):
@dataclass
class PacmanConfiguration:
class PacmanConfiguration(SubConfig):
parallel_downloads: int = 5
color: bool = True
@ -18,12 +19,19 @@ class PacmanConfiguration:
def default(cls) -> Self:
return cls()
@override
def json(self) -> PacmanConfigSerialization:
return {
'parallel_downloads': self.parallel_downloads,
'color': self.color,
}
@override
def summary(self) -> str | None:
if self.color:
return tr('Color enabled')
return None
def preview(self) -> str:
color_str = str(self.color)
output = '{}: {}\n'.format(tr('Parallel Downloads'), self.parallel_downloads)

View File

@ -1,8 +1,10 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Self, TypedDict
from typing import TYPE_CHECKING, Self, TypedDict, override
from archinstall.default_profiles.profile import GreeterType, Profile
from archinstall.lib.hardware import GfxDriver
from archinstall.lib.models.config import SubConfig
from archinstall.lib.translationhandler import tr
if TYPE_CHECKING:
from archinstall.lib.profile.profiles_handler import ProfileSerialization
@ -15,11 +17,12 @@ class _ProfileConfigurationSerialization(TypedDict):
@dataclass
class ProfileConfiguration:
class ProfileConfiguration(SubConfig):
profile: Profile | None = None
gfx_driver: GfxDriver | None = None
greeter: GreeterType | None = None
@override
def json(self) -> _ProfileConfigurationSerialization:
from archinstall.lib.profile.profiles_handler import profile_handler
@ -29,6 +32,23 @@ class ProfileConfiguration:
'greeter': self.greeter.value if self.greeter else None,
}
@override
def summary(self) -> list[str] | None:
out: list[str] = []
if self.profile:
out.append(self.profile.name)
if self.gfx_driver:
out.append(tr('{} grphics driver').format(self.gfx_driver.value))
if self.greeter:
out.append(tr('{} greeter').format(self.greeter.value))
return out
return None
@classmethod
def parse_arg(cls, arg: _ProfileConfigurationSerialization) -> Self:
from archinstall.lib.profile.profiles_handler import profile_handler

View File

@ -1,6 +1,8 @@
import logging
import os
import sys
import urllib.error
import urllib.request
from collections.abc import Callable
from dataclasses import asdict, is_dataclass
from datetime import UTC, datetime
@ -9,6 +11,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any
from archinstall.lib.utils.encoding import unicode_ljust, unicode_rjust
from archinstall.tui.rich import BaseRichTable
if TYPE_CHECKING:
from _typeshed import DataclassInstance
@ -18,7 +21,7 @@ class FormattedOutput:
@staticmethod
def _get_values(
o: DataclassInstance,
class_formatter: str | Callable | None = None, # type: ignore[type-arg]
class_formatter: str | Callable | None = None, # type: ignore[type-arg] # pyright: ignore[reportMissingTypeArgument]
filter_list: list[str] = [],
) -> dict[str, Any]:
"""
@ -127,6 +130,35 @@ class FormattedOutput:
return output
def as_key_value_pair(
entries: dict[str, str | list[str] | bool],
ignore_empty: bool = True,
) -> str:
"""
Formats key-values as a Rich Table:
key1 : value1
key2 : value2
...
"""
table = BaseRichTable()
table.add_column('key', style='bold', no_wrap=True)
table.add_column('value', style='white', max_width=70)
for label, value in entries.items():
if ignore_empty and not value:
continue
if isinstance(value, bool):
value = 'Yes' if value else 'No'
if isinstance(value, list):
value = '\n '.join(str(val) for val in value)
table.add_row(label.title(), f': {value}')
return table.stringify()
class Journald:
@staticmethod
def log(message: str, level: int = logging.DEBUG) -> None:
@ -333,3 +365,51 @@ def log(
if level != logging.DEBUG:
print(text)
def share_install_log(
paste_url: str = 'https://paste.rs',
max_size: int = 10 * 1024 * 1024,
confirm: Callable[[str], bool] = lambda _: True,
) -> int:
log_path = logger.path
if not log_path.exists():
info(f'Log file not found: {log_path}')
return 1
size = log_path.stat().st_size
if size == 0:
info(f'Log file is empty: {log_path}')
return 1
if size > max_size:
info(f'Log file exceeds {max_size} bytes, uploading last {max_size} bytes')
content = log_path.read_bytes()[-max_size:]
else:
content = log_path.read_bytes()
header = f'About to upload {log_path} ({len(content)} bytes) to {paste_url}\n\n'
header += 'The log may contain hostname, mirror URLs, package list and partition layout.\n'
header += 'The uploaded paste is public.\n\n'
header += 'Continue?'
if not confirm(header):
info('Cancelled.')
return 1
try:
req = urllib.request.Request(paste_url, data=content)
with urllib.request.urlopen(req) as response:
url = response.read().decode().strip()
except urllib.error.URLError as e:
info(f'Upload failed: {e}')
return 1
if not url.startswith('http'):
info(f'Unexpected response from {paste_url}: {url[:200]!r}')
return 1
# raw print so the URL is pipe-friendly (no ANSI colors, no log prefix)
print(url)
return 0

View File

@ -14,7 +14,7 @@ def installed_package(package: str) -> LocalPackage | None:
try:
package_info = []
for line in Pacman.run(f'-Q --info {package}'):
package_info.append(line.decode().strip())
package_info.append(line.decode().rstrip())
return _parse_package_output(package_info, LocalPackage)
except SysCallError:
@ -53,7 +53,7 @@ def available_package(package: str) -> AvailablePackage | None:
try:
package_info: list[str] = []
for line in Pacman.run(f'-S --info {package}'):
package_info.append(line.decode().strip())
package_info.append(line.decode().rstrip())
return _parse_package_output(package_info, AvailablePackage)
except SysCallError:
@ -79,7 +79,7 @@ def list_available_packages(
debug(f'Failed to sync Arch Linux package database: {e}')
for line in Pacman.run('-S --info'):
dec_line = line.decode().strip()
dec_line = line.decode().rstrip()
current_package.append(dec_line)
if dec_line.startswith('Validated'):
@ -187,6 +187,7 @@ async def select_additional_packages(
multi=True,
preview_location='right',
enable_filter=True,
wrap_preview=True,
).show()
match pck_result.type_:

View File

@ -2293,3 +2293,14 @@ msgstr ""
#, python-brace-format
msgid "Default: {}ms, Recommended range: 1000-60000"
msgstr ""
#, python-brace-format
msgid "{} needs access to your seat"
msgstr ""
msgid "collection of hardware devices i.e. keyboard, mouse"
msgstr ""
#, python-brace-format
msgid "Choose an option how to give {} access to your hardware"
msgstr ""

View File

@ -256,6 +256,9 @@ msgstr "Język"
msgid "Locale encoding"
msgstr "Kodowanie"
msgid "Console font"
msgstr "Czcionka konsoli"
msgid "Drive(s)"
msgstr "Dysk(i)"
@ -820,6 +823,19 @@ msgstr "Nieprawidłowa wartość! Spróbuj jeszcze raz z prawidłową wartości
msgid "Parallel Downloads"
msgstr "Pobieranie kilku plików jednocześnie"
msgid "Pacman"
msgstr "Pacman"
msgid "Color"
msgstr "Kolor"
#, python-brace-format
msgid "Enter the number of parallel downloads (1-{})"
msgstr "Wprowadź maksymalną liczbę plików pobieranych jednocześnie (1-{})"
msgid "Enable colored output for pacman"
msgstr "Włącz kolory w pacmanie"
msgid "ESC to skip"
msgstr "Naciśnij ESC, aby pominąć"
@ -1203,6 +1219,21 @@ msgstr "Produkt"
msgid "Invalid configuration: {error}"
msgstr "Niepoprawna konfiguracja: {error}"
msgid "Ready to install"
msgstr "Gotowe do zainstalowania"
msgid "Disks"
msgstr "Dyski"
msgid "Packages"
msgstr "Pakiety"
msgid "Network"
msgstr "Sieć"
msgid "Locale"
msgstr "Ustawienia regionalne (locale)"
msgid "Type"
msgstr "Typ"
@ -1660,6 +1691,123 @@ msgstr "Wejdź w tryb wyszukiwania"
msgid "Exit search mode"
msgstr "Wyjdź z trybu wyszukiwania"
msgid "General"
msgstr "Ogólne"
msgid "Navigation"
msgstr "Nawigacja"
msgid "Selection"
msgstr "Wybór"
msgid "Search"
msgstr "Szukaj"
msgid "Down"
msgstr "W dół"
msgid "Up"
msgstr "W górę"
msgid "Confirm"
msgstr "Potwierdź"
msgid "Focus right"
msgstr "W prawo"
msgid "Focus left"
msgstr "W lewo"
msgid "Toggle"
msgstr "Zaznacz/Odznacz"
msgid "Show/Hide help"
msgstr "Pokaż/Ukryj pomoc"
msgid "Quit"
msgstr "Wyjdź"
msgid "First"
msgstr "Pierwszy"
msgid "Last"
msgstr "Ostatni"
msgid "Select"
msgstr "Wybierz"
msgid "Page Up"
msgstr "Strona w górę"
msgid "Page Down"
msgstr "Strona w dół"
msgid "Page up"
msgstr "Strona w górę"
msgid "Page down"
msgstr "Strona w dół"
msgid "Page Left"
msgstr "Strona w lewo"
msgid "Page Right"
msgstr "Strona w prawo"
msgid "Cursor up"
msgstr "Kursor w górę"
msgid "Cursor down"
msgstr "Kursor w dół"
msgid "Cursor right"
msgstr "Kursor w prawo"
msgid "Cursor left"
msgstr "Kursor w lewo"
msgid "Top"
msgstr "Góra"
msgid "Bottom"
msgstr "Dół"
msgid "Home"
msgstr "Początek"
msgid "End"
msgstr "Koniec"
msgid "Toggle option"
msgstr "Zaznacz/Odznacz opcję"
msgid "Scroll Up"
msgstr "Przewiń w górę"
msgid "Scroll Down"
msgstr "Przewiń w dół"
msgid "Scroll Left"
msgstr "Przewiń w lewo"
msgid "Scroll Right"
msgstr "Przewiń w prawo"
msgid "Scroll Home"
msgstr "Przewiń do początku"
msgid "Scroll End"
msgstr "Przewiń do końca"
msgid "Focus Next"
msgstr "Następny"
msgid "Focus Previous"
msgstr "Poprzedni"
msgid "Copy selected text"
msgstr "Skopiuj zaznaczony tekst"
msgid "labwc needs access to your seat (collection of hardware devices i.e. keyboard, mouse, etc)"
msgstr "labwc potrzebuje dostępu do twojego seat'a (sprzętu, np. klawiatury, myszki)"
@ -1878,6 +2026,21 @@ msgstr "Użyj NetworkManager'a (backend iwd)"
msgid "Firewall"
msgstr "Zapora sieciowa"
msgid "Additional fonts"
msgstr "Dodatkowe czcionki"
msgid "Select font packages to install"
msgstr "Wybierz pakiety czcionek do zainstalowania"
msgid "Unicode font coverage for most languages"
msgstr "Wsparcie Unicode dla większości języków"
msgid "color emoji for browsers and apps"
msgstr "kolorowe emoji dla przeglądarek i aplikacji"
msgid "Chinese, Japanese, Korean characters"
msgstr "Chińskie, Japońskie, Koreańskie znaki"
msgid "Select audio configuration"
msgstr "Wybierz konfigurację audio"
@ -1965,7 +2128,13 @@ msgid "Select an interface"
msgstr "Wybierz interfejs"
msgid "Choose network configuration"
msgstr "Wybierz konfigurację sieciową"
msgstr "Wybierz konfigurację sieci"
msgid "Recommended: Network Manager for desktop, Manual for server"
msgstr "Rekomendowane: Network Manager dla desktopów, ręcznie dla serwerów"
msgid "Warning: no network configuration selected. Network will need to be set up manually on the installed system."
msgstr "Uwaga: nie wybrano konfiguracji sieci. Sieć musi zostać skonfigurowana ręcznie w zainstalowanym systemie."
msgid "No packages found"
msgstr "Nie znaleziono pakietów"
@ -2004,6 +2173,72 @@ msgstr "Wybrany profil pulpitu wymaga logowania normalnego użytkownika przez gr
msgid "Environment type: {} {}"
msgstr "Typ środowiska: {} {}"
msgid "Input cannot be empty"
msgstr "Podana wartość nie może być pusta"
msgid "Recommended"
msgstr "Rekomendowane"
msgid "Package"
msgstr "Pakiet"
msgid "Curated selection of KDE Plasma packages"
msgstr "Wyselekcjonowany zestaw pakietów KDE Plasma"
msgid "Dependencies"
msgstr "Zależności"
msgid "Package group"
msgstr "Grupa pakietów"
msgid "Extensive KDE Plasma installation"
msgstr "Obszerna instalacja KDE Plasma"
msgid "Packages in group"
msgstr "Pakiety w grupie"
msgid "Minimal KDE Plasma installation"
msgstr "Minimalistyczna instalacja KDE Plasma"
msgid "Description"
msgstr "Opis"
msgid "Select a flavor of KDE Plasma to install"
msgstr "Wybierz rodzaj KDE Plasma do zainstalowania"
msgid "Arial/Times/Courier replacement, Cyrillic support for Steam/games"
msgstr "Zamiennik Arial/Times/Courier, obsługa Cyrylicy dla Steam/gier"
msgid "wide Unicode coverage, good fallback font"
msgstr "szerokie wsparcie Unicode, dobra czczionka zastępcza"
#, python-brace-format
msgid "Setting up U2F login: {u2f_config.u2f_login_method.value}"
msgstr "Ustawianie logowania U2F {u2f_config.u2f_login_method.value}"
#, python-brace-format
msgid "Default: {DEFAULT_ITER_TIME}ms, Recommended range: 1000-60000"
msgstr "Domyślnie: {DEFAULT_ITER_TIME}ms, Rekomendowany przedział: 1000-60000"
#, python-brace-format
msgid "Setting up U2F login: {}"
msgstr "Konfigurowanie logowania U2F: {}"
#, python-brace-format
msgid "Default: {}ms, Recommended range: 1000-60000"
msgstr "Domyślnie: {}ms, Rekomendowany przedział: 1000-60000"
#, python-brace-format
msgid "{} needs access to your seat"
msgstr "{} potrzebuje dostępu do twojego seat'a"
msgid "collection of hardware devices i.e. keyboard, mouse"
msgstr "zbiór urządzeń, t.j. klawiatura, myszka"
#, python-brace-format
msgid "Choose an option how to give {} access to your hardware"
msgstr "Wybierz opcję, aby nadać {} dostęp do twojego sprzętu"
#~ msgid "When picking a directory to save configuration files to, by default we will ignore the following folders: "
#~ msgstr "Podczas wybierania katalogu do zapisywania plików konfiguracyjnych, domyślnie ignorowane są następujące foldery: "

View File

@ -11,14 +11,16 @@ from pathlib import Path
from archinstall.lib.args import ArchConfigHandler
from archinstall.lib.disk.utils import disk_layouts
from archinstall.lib.hardware import SysInfo
from archinstall.lib.menu.helpers import Confirmation
from archinstall.lib.network.wifi_handler import WifiHandler
from archinstall.lib.networking import ping
from archinstall.lib.output import debug, error, info, warn
from archinstall.lib.output import debug, error, info, share_install_log, warn
from archinstall.lib.packages.util import check_version_upgrade
from archinstall.lib.pacman.pacman import Pacman
from archinstall.lib.translationhandler import tr, translation_handler
from archinstall.lib.utils.util import running_from_iso
from archinstall.tui.components import tui
from archinstall.tui.menu_item import MenuItemGroup
def _log_sys_info() -> None:
@ -73,12 +75,28 @@ def _list_scripts() -> str:
return '\n'.join(lines)
def _tui_confirm(header: str) -> bool:
async def _ask() -> bool:
result = await Confirmation(
group=MenuItemGroup.yes_no(),
header=header,
allow_skip=False,
preset=False,
).show()
return result.get_value()
return tui.run(_ask)
def run() -> int:
"""
This can either be run as the compiled and installed application: python setup.py install
OR straight as a module: python -m archinstall
In any case we will be attempting to load the provided script to be run from the scripts/ folder
"""
if 'share-log' in sys.argv:
return share_install_log(confirm=_tui_confirm)
arch_config_handler = ArchConfigHandler()
if '--help' in sys.argv or '-h' in sys.argv:
@ -141,8 +159,8 @@ def _error_message(exc: Exception) -> None:
Archinstall experienced the above error. If you think this is a bug, please report it to
https://github.com/archlinux/archinstall and include the log file "/var/log/archinstall/install.log".
Hint: To extract the log from a live ISO
curl -F 'file=@/var/log/archinstall/install.log' https://0x0.st
Hint: To upload the log and get a shareable URL, run
archinstall share-log
"""
)
warn(text)

View File

@ -115,7 +115,11 @@ def perform_installation(
installation.setup_swap(algo=config.swap.algorithm)
if config.bootloader_config and config.bootloader_config.bootloader != Bootloader.NO_BOOTLOADER:
installation.add_bootloader(config.bootloader_config.bootloader, config.bootloader_config.uki, config.bootloader_config.removable)
installation.add_bootloader(
config.bootloader_config.bootloader,
config.bootloader_config.uki,
config.bootloader_config.removable,
)
if config.network_config:
install_network_config(

View File

@ -227,6 +227,11 @@ class OptionListScreen(BaseScreen[ValueT]):
color: white;
text-style: bold;
}
.wrap-preview {
width: 100%;
height: auto;
}
"""
def __init__(
@ -238,6 +243,7 @@ class OptionListScreen(BaseScreen[ValueT]):
allow_reset: bool = False,
preview_location: Literal['right', 'bottom'] | None = None,
enable_filter: bool = False,
wrap_preview: bool = False,
):
super().__init__(allow_skip, allow_reset)
self._group = group
@ -245,6 +251,7 @@ class OptionListScreen(BaseScreen[ValueT]):
self._title = title
self._preview_location = preview_location
self._filter = enable_filter
self._wrap_preview = wrap_preview
self._show_frame = False
self._options = self._get_options()
@ -307,7 +314,10 @@ class OptionListScreen(BaseScreen[ValueT]):
with Container():
yield option_list
yield Rule(orientation=rule_orientation)
yield ScrollableContainer(Label('', id='preview_content', markup=False))
preview_label = Label('', id='preview_content', markup=False)
if self._wrap_preview:
preview_label.add_class('wrap-preview')
yield ScrollableContainer(preview_label)
if self._filter:
yield Input(placeholder='/filter', id='filter-input')
@ -367,11 +377,6 @@ class OptionListScreen(BaseScreen[ValueT]):
]
)
# debug(f'Index: {index}')
# debug(f'Region: {option_list.region}')
# debug(f'Scroll offset: {option_list.scroll_offset}')
# debug(f'Target_Y: {target_y}')
self.app.cursor_position = Offset(option_list.region.x, target_y)
self.app.refresh()
@ -456,6 +461,11 @@ class SelectListScreen(BaseScreen[ValueT]):
color: white;
text-style: bold;
}
.wrap-preview {
width: 100%;
height: auto;
}
"""
def __init__(
@ -466,6 +476,7 @@ class SelectListScreen(BaseScreen[ValueT]):
allow_reset: bool = False,
preview_location: Literal['right', 'bottom'] | None = None,
enable_filter: bool = False,
wrap_preview: bool = False,
):
super().__init__(allow_skip, allow_reset)
self._group = group
@ -473,6 +484,7 @@ class SelectListScreen(BaseScreen[ValueT]):
self._preview_location = preview_location
self._show_frame = False
self._filter = enable_filter
self._wrap_preview = wrap_preview
self._selected_items: list[MenuItem] = self._group.selected_items
self._options: list[Selection[MenuItem]] = self._get_selections()
@ -533,7 +545,10 @@ class SelectListScreen(BaseScreen[ValueT]):
with Container():
yield selection_list
yield Rule(orientation=rule_orientation)
yield ScrollableContainer(Label('', id='preview_content', markup=False))
preview_label = Label('', id='preview_content', markup=False)
if self._wrap_preview:
preview_label.add_class('wrap-preview')
yield ScrollableContainer(preview_label)
if self._filter:
yield Input(placeholder='/filter', id='filter-input')
@ -1146,8 +1161,6 @@ class TableSelectionScreen(BaseScreen[ValueT]):
]
)
debug(f'Setting cursor to target_y: {target_y}')
self.app.cursor_position = Offset(data_table.region.x, target_y)
self.app.refresh()

25
archinstall/tui/rich.py Normal file
View File

@ -0,0 +1,25 @@
from io import StringIO
from rich import box
from rich.console import Console
from rich.table import Table as RichTable
class BaseRichTable(RichTable):
def __init__(self) -> None:
super().__init__(
box=box.SIMPLE,
show_header=False,
pad_edge=False,
show_edge=False,
)
def stringify(self) -> str:
string_io = StringIO()
buf = Console(file=string_io, highlight=False)
buf.print(self)
_ = string_io.seek(0)
output = string_io.read()
return output

View File

@ -15,7 +15,7 @@ When submitting a help ticket, please include the :code:`/var/log/archinstall/in
It can be found both on the live ISO but also in the installed filesystem if the base packages were strapped in.
.. tip::
| An easy way to submit logs is ``curl -F 'file=@/var/log/archinstall/install.log' https://0x0.st``.
| An easy way to submit logs is ``archinstall share-log``, which uploads ``install.log`` to paste.rs and prints a shareable URL.
| Use caution when submitting other log files, but ``archinstall`` pledges to keep ``install.log`` safe for posting publicly!
There are additional log files under ``/var/log/archinstall/`` that can be useful:

View File

@ -19,8 +19,8 @@ classifiers = [
]
dependencies = [
"pyparted==3.13.0",
"pydantic==2.12.5",
"cryptography==47.0.0",
"pydantic==2.13.4",
"cryptography==48.0.0",
"textual==8.2.5",
"markdown-it-py==4.0.0",
"linkify-it-py==2.1.0",
@ -34,7 +34,7 @@ Source = "https://github.com/archlinux/archinstall"
[project.optional-dependencies]
log = ["systemd_python==235"]
dev = [
"mypy==1.20.2",
"mypy==2.1.0",
"flake8==7.3.0",
"pre-commit==4.6.0",
"ruff==0.15.12",

94
tests/test_share_log.py Normal file
View File

@ -0,0 +1,94 @@
# pylint: disable=redefined-outer-name
import urllib.error
from io import BytesIO
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from archinstall.lib.output import share_install_log
@pytest.fixture()
def log_file(tmp_path: Path) -> Path:
log_dir = tmp_path / 'archinstall'
log_dir.mkdir()
return log_dir / 'install.log'
def _fake_logger(log_file: Path) -> MagicMock:
mock = MagicMock()
mock.path = log_file
return mock
def test_file_not_found(tmp_path: Path) -> None:
missing = tmp_path / 'no-such' / 'install.log'
with patch('archinstall.lib.output.logger', _fake_logger(missing)):
assert share_install_log() == 1
def test_empty_file(log_file: Path) -> None:
log_file.write_bytes(b'')
with patch('archinstall.lib.output.logger', _fake_logger(log_file)):
assert share_install_log() == 1
def test_user_cancels(log_file: Path) -> None:
log_file.write_text('some log content')
with patch('archinstall.lib.output.logger', _fake_logger(log_file)):
assert share_install_log(confirm=lambda _: False) == 1
def test_successful_upload(log_file: Path) -> None:
log_file.write_text('some log content')
fake_response = BytesIO(b'https://paste.rs/abc.def')
with (
patch('archinstall.lib.output.logger', _fake_logger(log_file)),
patch('urllib.request.urlopen', return_value=fake_response) as mock_open,
):
result = share_install_log()
assert result == 0
req = mock_open.call_args[0][0]
assert req.data == b'some log content'
def test_truncation(log_file: Path) -> None:
max_size = 100
content = b'A' * 50 + b'B' * 80
log_file.write_bytes(content)
fake_response = BytesIO(b'https://paste.rs/abc.def')
with (
patch('archinstall.lib.output.logger', _fake_logger(log_file)),
patch('urllib.request.urlopen', return_value=fake_response) as mock_open,
):
result = share_install_log(max_size=max_size)
assert result == 0
req = mock_open.call_args[0][0]
assert len(req.data) == max_size
assert req.data == content[-max_size:]
def test_network_error(log_file: Path) -> None:
log_file.write_text('some log content')
with (
patch('archinstall.lib.output.logger', _fake_logger(log_file)),
patch('urllib.request.urlopen', side_effect=urllib.error.URLError('no network')),
):
assert share_install_log() == 1
def test_unexpected_response(log_file: Path) -> None:
log_file.write_text('some log content')
fake_response = BytesIO(b'ERROR: something went wrong')
with (
patch('archinstall.lib.output.logger', _fake_logger(log_file)),
patch('urllib.request.urlopen', return_value=fake_response),
):
assert share_install_log() == 1