diff --git a/snigdhaos-calamares-config/modules/displaymanager/displaymanager.schema.yaml b/snigdhaos-calamares-config/modules/displaymanager/displaymanager.schema.yaml new file mode 100644 index 00000000..dcd2baa3 --- /dev/null +++ b/snigdhaos-calamares-config/modules/displaymanager/displaymanager.schema.yaml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/displaymanager +additionalProperties: false +type: object +properties: + displaymanagers: + type: array + items: + type: string + enum: [slim, sddm, lightdm, gdm, mdm, lxdm, greetd] + minItems: 1 # Must be non-empty, if present at all + defaultDesktopEnvironment: + type: object + properties: + executable: { type: string } + desktopFile: { type: string } + required: [ executable, desktopFile ] + basicSetup: { type: boolean, default: false } + sysconfigSetup: { type: boolean, default: false } + greetd: + type: object + properties: + greeter_user: { type: string } + greeter_group: { type: string } + greeter_css_location: { type: string } + additionalProperties: false + lightdm: + type: object + properties: + preferred_greeters: { type: array, items: { type: string } } + additionalProperties: false + sddm: + type: object + properties: + configuration_file: { type: string } + additionalProperties: false diff --git a/snigdhaos-calamares-config/modules/displaymanager/main.py b/snigdhaos-calamares-config/modules/displaymanager/main.py new file mode 100644 index 00000000..8bb18510 --- /dev/null +++ b/snigdhaos-calamares-config/modules/displaymanager/main.py @@ -0,0 +1,1036 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014-2018 Philip Müller +# SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac +# SPDX-FileCopyrightText: 2014 Kevin Kofler +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2017 Bernhard Landauer +# SPDX-FileCopyrightText: 2017 2019, Adriaan de Groot +# SPDX-FileCopyrightText: 2019 Dominic Hayes +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import abc +import os +import libcalamares + +from libcalamares.utils import gettext_path, gettext_languages + +import gettext +_translation = gettext.translation("calamares-python", + localedir=gettext_path(), + languages=gettext_languages(), + fallback=True) +_ = _translation.gettext +_n = _translation.ngettext + +class DesktopEnvironment: + """ + Desktop Environment -- some utility functions for a desktop + environment (e.g. finding out if it is installed). This + is independent of the *Display Manager*, which is what + we're configuring in this module. + """ + def __init__(self, exec, desktop): + self.executable = exec + self.desktop_file = desktop + + def _search_executable(self, root_mount_point, pathname): + """ + Search for @p pathname within @p root_mount_point . + If the pathname is absolute, just check there inside + the target, otherwise earch in a sort-of-sensible $PATH. + + Returns the full (including @p root_mount_point) path + to that executable, or None. + """ + if pathname.startswith("/"): + path = [""] + else: + path = ["/bin/", "/usr/bin/", "/sbin/", "/usr/local/bin/"] + + for p in path: + absolute_path = "{!s}{!s}{!s}".format(root_mount_point, p, pathname) + if os.path.exists(absolute_path): + return absolute_path + return None + + def _search_tryexec(self, root_mount_point, absolute_desktop_file): + """ + Check @p absolute_desktop_file for a TryExec line and, if that is + found, search for the command (executable pathname) within + @p root_mount_point. The .desktop file must live within the + target root. + + Returns the full (including @p root_mount_point) for the executable + from TryExec, or None. + """ + assert absolute_desktop_file.startswith(root_mount_point) + with open(absolute_desktop_file, "r") as f: + for tryexec_line in [x for x in f.readlines() if x.startswith("TryExec")]: + try: + key, value = tryexec_line.split("=") + if key.strip() == "TryExec": + return self._search_executable(root_mount_point, value.strip()) + except: + pass + return None + + def find_executable(self, root_mount_point): + """ + Returns the full path of the configured executable within @p root_mount_point, + or None if it isn't found. May search in a semi-sensible $PATH. + """ + return self._search_executable(root_mount_point, self.executable) + + def find_desktop_file(self, root_mount_point): + """ + Returns the full path of the .desktop file within @p root_mount_point, + or None if it isn't found. Searches both X11 and Wayland sessions. + """ + x11_sessions = "{!s}/usr/share/xsessions/{!s}.desktop".format(root_mount_point, self.desktop_file) + wayland_sessions = "{!s}/usr/share/wayland-sessions/{!s}.desktop".format(root_mount_point, self.desktop_file) + for candidate in (x11_sessions, wayland_sessions): + if os.path.exists(candidate): + return candidate + return None + + def is_installed(self, root_mount_point): + """ + Check if this environment is installed in the + target system at @p root_mount_point. + """ + desktop_file = self.find_desktop_file(root_mount_point) + if desktop_file is None: + return False + + return (self.find_executable(root_mount_point) is not None or + self._search_tryexec(root_mount_point, desktop_file) is not None) + + def update_from_desktop_file(self, root_mount_point): + """ + Find thie DE in the target system at @p root_mount_point. + This can update the *executable* configuration value if + the configured executable isn't found but the TryExec line + from the .desktop file is. + + The .desktop file is mandatory for a DE. + + Returns True if the DE is installed. + """ + desktop_file = self.find_desktop_file(root_mount_point) + if desktop_file is None: + return False + + executable_file = self.find_executable(root_mount_point) + if executable_file is not None: + # .desktop found and executable as well. + return True + + executable_file = self._search_tryexec(root_mount_point, desktop_file) + if executable_file is not None: + # Found from the .desktop file, so update own executable config + if root_mount_point and executable_file.startswith(root_mount_point): + executable_file = executable_file[len(root_mount_point):] + if not executable_file: + # Somehow chopped down to nothing + return False + + if executable_file[0] != "/": + executable_file = "/" + executable_file + self.executable = executable_file + return True + # This is to double-check + return self.is_installed(root_mount_point) + + +# This is the list of desktop environments that Calamares looks +# for; if no default environment is **explicitly** configured +# in the `displaymanager.conf` then the first one from this list +# that is found, is used. +# +# Each DE has a sample executable to look for, and a .desktop filename. +# If the executable exists, the DE is assumed to be installed +# and to use the given .desktop filename. +# +# If the .desktop file exists and contains a TryExec line and that +# TryExec executable exists (searched in /bin, /usr/bin, /sbin and +# /usr/local/bin) then the DE is assumed to be installed +# and to use that .desktop filename. +desktop_environments = [ + DesktopEnvironment('/usr/bin/startplasma-x11', 'plasma'), # KDE Plasma 5.17+ + DesktopEnvironment('/usr/bin/startkde', 'plasma'), # KDE Plasma 5 + DesktopEnvironment('/usr/bin/startkde', 'kde-plasma'), # KDE Plasma 4 + DesktopEnvironment( + '/usr/bin/budgie-desktop', 'budgie-desktop' # Budgie v10 + ), + DesktopEnvironment( + '/usr/bin/budgie-session', 'budgie-desktop' # Budgie v8 + ), + DesktopEnvironment('/usr/bin/io.elementary.wingpanel', 'pantheon'), + DesktopEnvironment('/usr/bin/gnome-session', 'gnome-xorg'), + DesktopEnvironment('/usr/bin/cinnamon-session-cinnamon', 'cinnamon'), + DesktopEnvironment('/usr/bin/mate-session', 'mate'), + DesktopEnvironment('/usr/bin/enlightenment_start', 'enlightenment'), + DesktopEnvironment('/usr/bin/lxsession', 'LXDE'), + DesktopEnvironment('/usr/bin/startlxde', 'LXDE'), + DesktopEnvironment('/usr/bin/lxqt-session', 'lxqt'), + DesktopEnvironment('/usr/bin/pekwm', 'pekwm'), + DesktopEnvironment('/usr/bin/dde-session', 'dde-x11'), + DesktopEnvironment('/usr/bin/startxfce4', 'xfce'), + DesktopEnvironment('/usr/bin/openbox-session', 'openbox'), + DesktopEnvironment('/usr/bin/i3', 'i3'), + DesktopEnvironment('/usr/bin/awesome', 'awesome'), + DesktopEnvironment('/usr/bin/bspwm', 'bspwm'), + DesktopEnvironment('/usr/bin/herbstluftwm', 'herbstluftwm'), + DesktopEnvironment('/usr/bin/qtile', 'qtile'), + DesktopEnvironment('/usr/bin/xmonad', 'xmonad'), + DesktopEnvironment('/usr/bin/dwm', 'dwm'), + DesktopEnvironment('/usr/bin/jwm', 'jwm'), + DesktopEnvironment('/usr/bin/icewm-session', 'icewm-session'), + DesktopEnvironment('/usr/bin/fvwm3', 'fvwm3'), + DesktopEnvironment('/usr/bin/Hyprland', 'hyprland'), + DesktopEnvironment('/usr/bin/sway', 'sway'), + DesktopEnvironment('/usr/bin/ukui-session', 'ukui'), + DesktopEnvironment('/usr/bin/cutefish-session', 'cutefish-xsession'), + DesktopEnvironment('/usr/bin/wmderland', 'wmderland'), + DesktopEnvironment('/usr/bin/leftwm', 'leftwm'), + DesktopEnvironment('/usr/bin/berry', 'berry'), + DesktopEnvironment('/usr/bin/worm', 'worm'), + DesktopEnvironment('/usr/bin/river', 'river'), + DesktopEnvironment('/usr/bin/nimdow', 'nimdow'), + DesktopEnvironment('/usr/bin/wayfire', 'wayfire'), +] + + +def find_desktop_environment(root_mount_point): + """ + Checks which desktop environment is currently installed. + + :param root_mount_point: + :return: + """ + libcalamares.utils.debug("Using rootMountPoint {!r}".format(root_mount_point)) + for desktop_environment in desktop_environments: + if desktop_environment.is_installed(root_mount_point): + libcalamares.utils.debug(".. selected DE {!s}".format(desktop_environment.desktop_file)) + return desktop_environment + return None + + +class DisplayManager(metaclass=abc.ABCMeta): + """ + Display Manager -- a base class for DM configuration. + """ + name = None + executable = None + + def __init__(self, root_mount_point): + self.root_mount_point = root_mount_point + + def have_dm(self): + """ + Is this DM installed in the target system? + The default implementation checks for `executable` + in the target system. + """ + if self.executable is None: + return False + + bin_path = "{!s}/usr/bin/{!s}".format(self.root_mount_point, self.executable) + sbin_path = "{!s}/usr/sbin/{!s}".format(self.root_mount_point, self.executable) + return os.path.exists(bin_path) or os.path.exists(sbin_path) + + # The four abstract methods below are called in the order listed here. + # They must all be implemented by subclasses, but not all of them + # actually do something for all DMs. + + @abc.abstractmethod + def basic_setup(self): + """ + Do basic setup (e.g. users, groups, directory creation) for this DM. + """ + # Some implementations do nothing + + @abc.abstractmethod + def desktop_environment_setup(self, desktop_environment): + """ + Configure the given @p desktop_environment as the default one, in + the configuration files for this DM. + """ + # Many implementations do nothing + + @abc.abstractmethod + def greeter_setup(self): + """ + Additional setup for the greeter. + """ + # Most implementations do nothing + + @abc.abstractmethod + def set_autologin(self, username, do_autologin, default_desktop_environment): + """ + Configure the DM inside the given @p root_mount_point with + autologin (if @p do_autologin is True) for the given @p username. + If the DM supports it, set the default DE to @p default_desktop_environment + as well. + """ + + +class DMmdm(DisplayManager): + name = "mdm" + executable = "mdm" + + def set_autologin(self, username, do_autologin, default_desktop_environment): + # Systems with MDM as Desktop Manager + mdm_conf_path = os.path.join(self.root_mount_point, "etc/mdm/custom.conf") + + if os.path.exists(mdm_conf_path): + with open(mdm_conf_path, 'r') as mdm_conf: + text = mdm_conf.readlines() + + with open(mdm_conf_path, 'w') as mdm_conf: + for line in text: + if 'AutomaticLogin=' in line: + line = "" + if 'AutomaticLoginEnable=True' in line: + line = "" + if '[daemon]' in line: + if do_autologin: + line = ( + "[daemon]\n" + "AutomaticLogin={!s}\n" + "AutomaticLoginEnable=True\n".format(username) + ) + else: + line = ( + "[daemon]\n" + "AutomaticLoginEnable=False\n" + ) + + mdm_conf.write(line) + else: + with open(mdm_conf_path, 'w') as mdm_conf: + mdm_conf.write( + '# Calamares - Configure automatic login for user\n' + ) + mdm_conf.write('[daemon]\n') + + if do_autologin: + mdm_conf.write("AutomaticLogin={!s}\n".format(username)) + mdm_conf.write('AutomaticLoginEnable=True\n') + else: + mdm_conf.write('AutomaticLoginEnable=False\n') + + def basic_setup(self): + if libcalamares.utils.target_env_call( + ['getent', 'group', 'mdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['groupadd', '-g', '128', 'mdm'] + ) + + if libcalamares.utils.target_env_call( + ['getent', 'passwd', 'mdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['useradd', + '-c', '"Linux Mint Display Manager"', + '-u', '128', + '-g', 'mdm', + '-d', '/var/lib/mdm', + '-s', '/usr/bin/nologin', + 'mdm' + ] + ) + + libcalamares.utils.target_env_call( + ['passwd', '-l', 'mdm'] + ) + libcalamares.utils.target_env_call( + ['chown', 'root:mdm', '/var/lib/mdm'] + ) + libcalamares.utils.target_env_call( + ['chmod', '1770', '/var/lib/mdm'] + ) + + def desktop_environment_setup(self, default_desktop_environment): + os.system( + "sed -i \"s|default.desktop|{!s}.desktop|g\" " + "{!s}/etc/mdm/custom.conf".format( + default_desktop_environment.desktop_file, + self.root_mount_point + ) + ) + + def greeter_setup(self): + pass + + +class DMgdm(DisplayManager): + name = "gdm" + executable = "gdm" + config = None # Set by have_dm() + + def have_dm(self): + """ + GDM exists with different executable names, so search + for one of them and use it. + """ + for executable, config in ( + ( "gdm", "etc/gdm/custom.conf" ), + ( "gdm3", "etc/gdm3/daemon.conf" ) + ): + bin_path = "{!s}/usr/bin/{!s}".format(self.root_mount_point, executable) + sbin_path = "{!s}/usr/sbin/{!s}".format(self.root_mount_point, executable) + if os.path.exists(bin_path) or os.path.exists(sbin_path): + # Keep the found-executable name around for later + self.executable = executable + self.config = config + return True + + return False + + def set_autologin(self, username, do_autologin, default_desktop_environment): + if self.config is None: + raise ValueError( "No config file for GDM has been set." ) + + # Systems with GDM as Desktop Manager + gdm_conf_path = os.path.join(self.root_mount_point, self.config) + + if os.path.exists(gdm_conf_path): + with open(gdm_conf_path, 'r') as gdm_conf: + text = gdm_conf.readlines() + + with open(gdm_conf_path, 'w') as gdm_conf: + for line in text: + if 'AutomaticLogin=' in line: + line = "" + if 'AutomaticLoginEnable=True' in line: + line = "" + if '[daemon]' in line: + if do_autologin: + line = ( + "[daemon]\n" + "AutomaticLogin={!s}\n" + "AutomaticLoginEnable=True\n".format(username) + ) + else: + line = "[daemon]\nAutomaticLoginEnable=False\n" + + gdm_conf.write(line) + else: + with open(gdm_conf_path, 'w') as gdm_conf: + gdm_conf.write( + '# Calamares - Enable automatic login for user\n' + ) + gdm_conf.write('[daemon]\n') + + if do_autologin: + gdm_conf.write("AutomaticLogin={!s}\n".format(username)) + gdm_conf.write('AutomaticLoginEnable=True\n') + else: + gdm_conf.write('AutomaticLoginEnable=False\n') + + if (do_autologin): + accountservice_dir = "{!s}/var/lib/AccountsService/users".format( + self.root_mount_point + ) + userfile_path = "{!s}/{!s}".format(accountservice_dir, username) + if os.path.exists(accountservice_dir): + with open(userfile_path, "w") as userfile: + userfile.write("[User]\n") + + if default_desktop_environment is not None: + userfile.write("XSession={!s}\n".format( + default_desktop_environment.desktop_file)) + + userfile.write("Icon=\n") + + def basic_setup(self): + if libcalamares.utils.target_env_call( + ['getent', 'group', 'gdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['groupadd', '-g', '120', 'gdm'] + ) + + if libcalamares.utils.target_env_call( + ['getent', 'passwd', 'gdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['useradd', + '-c', '"Gnome Display Manager"', + '-u', '120', + '-g', 'gdm', + '-d', '/var/lib/gdm', + '-s', '/usr/bin/nologin', + 'gdm' + ] + ) + + libcalamares.utils.target_env_call( + ['passwd', '-l', 'gdm'] + ) + libcalamares.utils.target_env_call( + ['chown', '-R', 'gdm:gdm', '/var/lib/gdm'] + ) + + def desktop_environment_setup(self, desktop_environment): + pass + + def greeter_setup(self): + pass + + +class DMlxdm(DisplayManager): + name = "lxdm" + executable = "lxdm" + + def set_autologin(self, username, do_autologin, default_desktop_environment): + # Systems with LXDM as Desktop Manager + lxdm_conf_path = os.path.join(self.root_mount_point, "etc/lxdm/lxdm.conf") + text = [] + + if os.path.exists(lxdm_conf_path): + with open(lxdm_conf_path, 'r') as lxdm_conf: + text = lxdm_conf.readlines() + + with open(lxdm_conf_path, 'w') as lxdm_conf: + for line in text: + if 'autologin=' in line: + if do_autologin: + line = "autologin={!s}\n".format(username) + else: + line = "# autologin=\n" + + lxdm_conf.write(line) + else: + return ( + _("Cannot write LXDM configuration file"), + _("LXDM config file {!s} does not exist").format(lxdm_conf_path) + ) + + def basic_setup(self): + if libcalamares.utils.target_env_call( + ['getent', 'group', 'lxdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['groupadd', '--system', 'lxdm'] + ) + + libcalamares.utils.target_env_call( + ['chgrp', '-R', 'lxdm', '/var/lib/lxdm'] + ) + libcalamares.utils.target_env_call( + ['chgrp', 'lxdm', '/etc/lxdm/lxdm.conf'] + ) + libcalamares.utils.target_env_call( + ['chmod', '+r', '/etc/lxdm/lxdm.conf'] + ) + + def desktop_environment_setup(self, default_desktop_environment): + os.system( + "sed -i -e \"s|^.*session=.*|session={!s}|\" " + "{!s}/etc/lxdm/lxdm.conf".format( + default_desktop_environment.executable, + self.root_mount_point + ) + ) + + def greeter_setup(self): + pass + + +class DMlightdm(DisplayManager): + name = "lightdm" + executable = "lightdm" + + # Can be overridden in the .conf file. With no value it won't match any + # desktop file in the xgreeters directory and instead we end up picking + # the alphabetically first file there. + preferred_greeters = [] + + def set_autologin(self, username, do_autologin, default_desktop_environment): + # Systems with LightDM as Desktop Manager + # Ideally, we should use configparser for the ini conf file, + # but we just do a simple text replacement for now, as it + # worksforme(tm) + lightdm_conf_path = os.path.join( + self.root_mount_point, "etc/lightdm/lightdm.conf" + ) + text = [] + addseat = False + loopcount = 0 + + if os.path.exists(lightdm_conf_path): + with open(lightdm_conf_path, 'r') as lightdm_conf: + text = lightdm_conf.readlines() + # Check to make sure [SeatDefaults] or [Seat:*] is in the config, + # otherwise we'll risk malforming the config + addseat = '[SeatDefaults]' not in text and '[Seat:*]' not in text + + with open(lightdm_conf_path, 'w') as lightdm_conf: + if addseat: + # Prepend Seat line to start of file rather than leaving it without one + # This keeps the config from being malformed for LightDM + text = ["[Seat:*]\n"] + text + for line in text: + if 'autologin-user=' in line: + if do_autologin: + line = "autologin-user={!s}\n".format(username) + else: + line = "#autologin-user=\n" + + lightdm_conf.write(line) + else: + try: + # Create a new lightdm.conf file; this is documented to be + # read last, after everything in lightdm.conf.d/ + with open(lightdm_conf_path, 'w') as lightdm_conf: + if do_autologin: + lightdm_conf.write( + "[Seat:*]\nautologin-user={!s}\n".format(username)) + else: + lightdm_conf.write( + "[Seat:*]\n#autologin-user=\n") + except FileNotFoundError: + return ( + _("Cannot write LightDM configuration file"), + _("LightDM config file {!s} does not exist").format(lightdm_conf_path) + ) + + def basic_setup(self): + libcalamares.utils.target_env_call( + ['mkdir', '-p', '/run/lightdm'] + ) + + if libcalamares.utils.target_env_call( + ['getent', 'group', 'lightdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['groupadd', '-g', '620', 'lightdm'] + ) + + if libcalamares.utils.target_env_call( + ['getent', 'passwd', 'lightdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['useradd', '-c', + '"LightDM Display Manager"', + '-u', '620', + '-g', 'lightdm', + '-d', '/var/run/lightdm', + '-s', '/usr/bin/nologin', + 'lightdm' + ] + ) + + libcalamares.utils.target_env_call(['passwd', '-l', 'lightdm']) + libcalamares.utils.target_env_call(['chown', '-R', 'lightdm:lightdm', '/run/lightdm']) + libcalamares.utils.target_env_call(['chmod', '+r' '/etc/lightdm/lightdm.conf']) + + def desktop_environment_setup(self, default_desktop_environment): + os.system( + "sed -i -e \"s/^.*user-session=.*/user-session={!s}/\" " + "{!s}/etc/lightdm/lightdm.conf".format( + default_desktop_environment.desktop_file, + self.root_mount_point + ) + ) + + def find_preferred_greeter(self): + """ + On Debian, lightdm-greeter.desktop is typically a symlink managed + by update-alternatives pointing to /etc/alternatives/lightdm-greeter + which is also a symlink to a real .desktop file back in /usr/share/xgreeters/ + + Returns a path *into the mounted target* of the preferred greeter -- usually + a .desktop file that specifies where the actual executable is. May return + None to indicate nothing-was-found. + """ + greeters_dir = "usr/share/xgreeters" + greeters_target_path = os.path.join(self.root_mount_point, greeters_dir) + available_names = os.listdir(greeters_target_path) + available_names.sort() + desktop_names = [n for n in self.preferred_greeters if n in available_names] # Preferred ones + if desktop_names: + return desktop_names[0] + desktop_names = [n for n in available_names if n.endswith(".desktop")] # .. otherwise any .desktop + if desktop_names: + return desktop_names[0] + return None + + def greeter_setup(self): + lightdm_conf_path = os.path.join(self.root_mount_point, "etc/lightdm/lightdm.conf") + greeter_name = self.find_preferred_greeter() + + if greeter_name is not None: + greeter = os.path.basename(greeter_name) # Follow symlinks, hope they are not absolute + if greeter.endswith('.desktop'): + greeter = greeter[:-8] # Remove ".desktop" from end + + libcalamares.utils.debug("found greeter {!s}".format(greeter)) + os.system( + "sed -i -e \"s/^.*greeter-session=.*" + "/greeter-session={!s}/\" {!s}".format( + greeter, + lightdm_conf_path + ) + ) + libcalamares.utils.debug("{!s} configured as greeter.".format(greeter)) + else: + libcalamares.utils.error("No greeter found at all, preferred {!s}".format(self.preferred_greeters)) + return ( + _("Cannot configure LightDM"), + _("No LightDM greeter installed.") + ) + + +class DMslim(DisplayManager): + name = "slim" + executable = "slim" + + def set_autologin(self, username, do_autologin, default_desktop_environment): + # Systems with Slim as Desktop Manager + slim_conf_path = os.path.join(self.root_mount_point, "etc/slim.conf") + text = [] + + if os.path.exists(slim_conf_path): + with open(slim_conf_path, 'r') as slim_conf: + text = slim_conf.readlines() + + with open(slim_conf_path, 'w') as slim_conf: + for line in text: + if 'auto_login' in line: + if do_autologin: + line = 'auto_login yes\n' + else: + line = 'auto_login no\n' + + if do_autologin and 'default_user' in line: + line = "default_user {!s}\n".format(username) + + slim_conf.write(line) + else: + return ( + _("Cannot write SLIM configuration file"), + _("SLIM config file {!s} does not exist").format(slim_conf_path) + ) + + + def basic_setup(self): + pass + + def desktop_environment_setup(self, desktop_environment): + pass + + def greeter_setup(self): + pass + + +class DMsddm(DisplayManager): + name = "sddm" + executable = "sddm" + + configuration_file = "/etc/sddm.conf.d/kde_settings.conf" + + def set_autologin(self, username, do_autologin, default_desktop_environment): + import configparser + + # Systems with Sddm as Desktop Manager + sddm_conf_path = os.path.join(self.root_mount_point, self.configuration_file.lstrip('/')) + + sddm_config = configparser.ConfigParser(strict=False) + # Make everything case sensitive + sddm_config.optionxform = str + + if os.path.isfile(sddm_conf_path): + sddm_config.read(sddm_conf_path) + + if 'Autologin' not in sddm_config: + sddm_config.add_section('Autologin') + + if do_autologin: + sddm_config.set('Autologin', 'User', username) + elif sddm_config.has_option('Autologin', 'User'): + sddm_config.remove_option('Autologin', 'User') + + if default_desktop_environment is not None: + sddm_config.set( + 'Autologin', + 'Session', + default_desktop_environment.desktop_file + ) + + with open(sddm_conf_path, 'w') as sddm_config_file: + sddm_config.write(sddm_config_file, space_around_delimiters=False) + + + def basic_setup(self): + pass + + def desktop_environment_setup(self, desktop_environment): + pass + + def greeter_setup(self): + pass + + +class DMgreetd(DisplayManager): + name = "greetd" + executable = "greetd" + greeter_user = "greeter" + greeter_group = "greetd" + greeter_css_location = None + config_data = {} + + def os_path(self, path): + return os.path.join(self.root_mount_point, path) + + def config_path(self): + return self.os_path("etc/greetd/config.toml") + + def environments_path(self): + return self.os_path("etc/greetd/environments") + + def config_load(self): + import toml + + if (os.path.exists(self.config_path())): + with open(self.config_path(), "r") as f: + self.config_data = toml.load(f) + + self.config_data['terminal'] = dict(vt = "next") + + default_session_group = self.config_data.get('default_session', None) + if not default_session_group: + self.config_data['default_session'] = {} + + self.config_data['default_session']['user'] = self.greeter_user + + return self.config_data + + def config_write(self): + import toml + with open(self.config_path(), "w") as f: + toml.dump(self.config_data, f) + + def basic_setup(self): + if libcalamares.utils.target_env_call( + ['getent', 'group', self.greeter_group] + ) != 0: + libcalamares.utils.target_env_call( + ['groupadd', self.greeter_group] + ) + + if libcalamares.utils.target_env_call( + ['getent', 'passwd', self.greeter_user] + ) != 0: + libcalamares.utils.target_env_call( + ['useradd', + '-c', '"Greeter User"', + '-g', self.greeter_group, + '-s', '/bin/bash', + self.greeter_user + ] + ) + + def desktop_environment_setup(self, default_desktop_environment): + with open(self.environments_path(), 'w') as envs_file: + envs_file.write(default_desktop_environment.executable) + envs_file.write("\n") + + def greeter_setup(self): + pass + + def set_autologin(self, username, do_autologin, default_desktop_environment): + self.config_load() + + de_command = default_desktop_environment.executable + if os.path.exists(self.os_path("usr/bin/gtkgreet")) and os.path.exists(self.os_path("usr/bin/cage")): + self.config_data['default_session']['command'] = "cage -d -s -- gtkgreet" + if self.greeter_css_location: + self.config_data['default_session']['command'] += f" -s {self.greeter_css_location}" + elif os.path.exists(self.os_path("usr/bin/tuigreet")): + tuigreet_base_cmd = "tuigreet --remember --time --issue --asterisks --cmd " + self.config_data['default_session']['command'] = tuigreet_base_cmd + de_command + elif os.path.exists(self.os_path("usr/bin/ddlm")): + self.config_data['default_session']['command'] = "ddlm --target " + de_command + else: + self.config_data['default_session']['command'] = "agreety --cmd " + de_command + + if do_autologin: + # Log in as user, with given DE + self.config_data['initial_session'] = dict(command = de_command, user = username) + elif 'initial_session' in self.config_data: + # No autologin, remove any autologin that was copied from the live ISO + del self.config_data['initial_session'] + + self.config_write() + + +class DMsysconfig(DisplayManager): + name = "sysconfig" + executable = None + + def set_autologin(self, username, do_autologin, default_desktop_environment): + dmauto = "DISPLAYMANAGER_AUTOLOGIN" + + os.system( + "sed -i -e 's|^{!s}=.*|{!s}=\"{!s}\"|' " + "{!s}/etc/sysconfig/displaymanager".format( + dmauto, dmauto, + username if do_autologin else "", + self.root_mount_point + ) + ) + + + def basic_setup(self): + pass + + def desktop_environment_setup(self, desktop_environment): + pass + + def greeter_setup(self): + pass + + # For openSUSE-derivatives, there is only sysconfig to configure, + # and no special DM configuration for it. Instead, check that + # sysconfig is available in the target. + def have_dm(self): + config = "{!s}/etc/sysconfig/displaymanager".format(self.root_mount_point) + return os.path.exists(config) + + +# Collect all the subclasses of DisplayManager defined above, +# and index them based on the name property of each class. +display_managers = [ + (c.name, c) + for c in globals().values() + if type(c) is abc.ABCMeta and issubclass(c, DisplayManager) and c.name +] + + +def run(): + """ + Configure display managers. + + We acquire a list of displaymanagers, either from config or (overridden) + from globalstorage. This module will try to set up (including autologin) + all the displaymanagers in the list, in that specific order. Most distros + will probably only ship one displaymanager. + If a displaymanager is in the list but not installed, a debugging message + is printed and the entry ignored. + """ + # Get configuration settings for display managers + displaymanagers = None + if "displaymanagers" in libcalamares.job.configuration: + displaymanagers = libcalamares.job.configuration["displaymanagers"] + + if libcalamares.globalstorage.contains("displayManagers"): + displaymanagers = libcalamares.globalstorage.value("displayManagers") + + if ("sysconfigSetup" in libcalamares.job.configuration + and libcalamares.job.configuration["sysconfigSetup"]): + displaymanagers = ["sysconfig"] + + if not displaymanagers: + return ( + _("No display managers selected for the displaymanager module."), + _("The displaymanagers list is empty or undefined in both " + "globalstorage and displaymanager.conf.") + ) + + # Get instances that are actually installed + root_mount_point = libcalamares.globalstorage.value("rootMountPoint") + dm_impl = [] + dm_names = displaymanagers[:] + for dm in dm_names: + # Find the implementation class + dm_instance = None + impl = [ cls for name, cls in display_managers if name == dm ] + if len(impl) == 1: + dm_instance = impl[0](root_mount_point) + if dm_instance.have_dm(): + dm_impl.append(dm_instance) + else: + dm_instance = None + else: + libcalamares.utils.debug("{!s} has {!s} implementation classes.".format(dm, len(impl))) + + if dm_instance is None: + libcalamares.utils.debug("{!s} selected but not installed".format(dm)) + if dm in displaymanagers: + displaymanagers.remove(dm) + + if not dm_impl: + libcalamares.utils.warning( + "No display managers selected for the displaymanager module. " + "The list is empty after checking for installed display managers." + ) + return None + + # Pick up remaining settings + if "defaultDesktopEnvironment" in libcalamares.job.configuration: + entry = libcalamares.job.configuration["defaultDesktopEnvironment"] + default_desktop_environment = DesktopEnvironment( + entry["executable"], entry["desktopFile"] + ) + # Adjust if executable is bad, but desktopFile isn't. + if not default_desktop_environment.update_from_desktop_file(root_mount_point): + libcalamares.utils.warning( + "The configured default desktop environment, {!s}, " + "can not be found.".format(default_desktop_environment.desktop_file)) + else: + default_desktop_environment = find_desktop_environment( + root_mount_point + ) + + if "basicSetup" in libcalamares.job.configuration: + enable_basic_setup = libcalamares.job.configuration["basicSetup"] + else: + enable_basic_setup = False + + username = libcalamares.globalstorage.value("autoLoginUser") + if username is not None: + do_autologin = True + libcalamares.utils.debug("Setting up autologin for user {!s}.".format(username)) + else: + do_autologin = False + libcalamares.utils.debug("Unsetting autologin.") + + libcalamares.globalstorage.insert("displayManagers", displaymanagers) + + # Do the actual configuration and collect messages + dm_setup_message = [] + for dm in dm_impl: + dm_specific_configuration = libcalamares.job.configuration.get(dm.name, None) + if dm_specific_configuration and isinstance(dm_specific_configuration, dict): + for k, v in dm_specific_configuration.items(): + if hasattr(dm, k): + setattr(dm, k, v) + dm_message = None + if enable_basic_setup: + dm_message = dm.basic_setup() + if default_desktop_environment is not None and dm_message is None: + dm_message = dm.desktop_environment_setup(default_desktop_environment) + if dm_message is None: + dm_message = dm.greeter_setup() + if dm_message is None: + dm_message = dm.set_autologin(username, do_autologin, default_desktop_environment) + + if dm_message is not None: + dm_setup_message.append("{!s}: {!s}".format(*dm_message)) + + if dm_setup_message: + return ( + _("Display manager configuration was incomplete"), + "\n".join(dm_setup_message) + ) diff --git a/snigdhaos-calamares-config/modules/displaymanager/module.desc b/snigdhaos-calamares-config/modules/displaymanager/module.desc new file mode 100644 index 00000000..a5894187 --- /dev/null +++ b/snigdhaos-calamares-config/modules/displaymanager/module.desc @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "displaymanager" +interface: "python" +script: "main.py" diff --git a/snigdhaos-calamares-config/modules/displaymanager/tests/1.global b/snigdhaos-calamares-config/modules/displaymanager/tests/1.global new file mode 100644 index 00000000..ee06ccfe --- /dev/null +++ b/snigdhaos-calamares-config/modules/displaymanager/tests/1.global @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +rootMountPoint: /tmp diff --git a/snigdhaos-calamares-config/modules/displaymanager/tests/CMakeTests.txt b/snigdhaos-calamares-config/modules/displaymanager/tests/CMakeTests.txt new file mode 100644 index 00000000..70e3d580 --- /dev/null +++ b/snigdhaos-calamares-config/modules/displaymanager/tests/CMakeTests.txt @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# We have tests to load (some) of the DMs specifically, to test their +# configuration code. Those tests conventionally live in Python +# files here in the tests/ directory. Add them. +foreach(_dmname greetd sddm) + add_test( + NAME configure-displaymanager-${_dmname} + COMMAND env PYTHONPATH=.: python3 ${CMAKE_CURRENT_LIST_DIR}/test-dm-${_dmname}.py + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + ) +endforeach() diff --git a/snigdhaos-calamares-config/modules/displaymanager/tests/test-dm-greetd.py b/snigdhaos-calamares-config/modules/displaymanager/tests/test-dm-greetd.py new file mode 100644 index 00000000..e2682afc --- /dev/null +++ b/snigdhaos-calamares-config/modules/displaymanager/tests/test-dm-greetd.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Calamares Boilerplate +import libcalamares +libcalamares.globalstorage = libcalamares.GlobalStorage(None) +libcalamares.globalstorage.insert("testing", True) + +# Module prep-work +from src.modules.displaymanager import main +default_desktop_environment = main.DesktopEnvironment("startplasma-x11", "kde-plasma.desktop") + +import os +os.makedirs("/tmp/etc/greetd/", exist_ok=True) +try: + os.remove("/tmp/etc/greetd/config.toml") +except FileNotFoundError as e: + pass + +try: + import toml +except ImportError: + # This is a failure of the test-environment. + import sys + print("Can't find module toml.", file=sys.stderr) + sys.exit(0) + +# Specific DM test +d = main.DMgreetd("/tmp") +d.set_autologin("d", True, default_desktop_environment) +# .. and again (this time checks load/save) +d.set_autologin("d", True, default_desktop_environment) +d.set_autologin("d", True, default_desktop_environment) diff --git a/snigdhaos-calamares-config/modules/displaymanager/tests/test-dm-sddm.py b/snigdhaos-calamares-config/modules/displaymanager/tests/test-dm-sddm.py new file mode 100644 index 00000000..b5c33494 --- /dev/null +++ b/snigdhaos-calamares-config/modules/displaymanager/tests/test-dm-sddm.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Calamares Boilerplate +import libcalamares +libcalamares.globalstorage = libcalamares.GlobalStorage(None) +libcalamares.globalstorage.insert("testing", True) + +# Module prep-work +from src.modules.displaymanager import main +default_desktop_environment = main.DesktopEnvironment("startplasma-x11", "kde-plasma.desktop") + +# Specific DM test +d = main.DMsddm("/tmp") +d.set_autologin("d", True, default_desktop_environment) +# .. and again (this time checks load/save) +d.set_autologin("d", True, default_desktop_environment) +d.set_autologin("d", True, default_desktop_environment) diff --git a/snigdhaos-calamares-config/modules/packages/main.py b/snigdhaos-calamares-config/modules/packages/main.py new file mode 100644 index 00000000..05552687 --- /dev/null +++ b/snigdhaos-calamares-config/modules/packages/main.py @@ -0,0 +1,819 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014 Pier Luigi Fiorini +# SPDX-FileCopyrightText: 2015-2017 Teo Mrnjavac +# SPDX-FileCopyrightText: 2016-2017 Kyle Robbertze +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2018 Adriaan de Groot +# SPDX-FileCopyrightText: 2018 Philip Müller +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import abc +from string import Template +import subprocess + +import libcalamares +from libcalamares.utils import check_target_env_call, target_env_call +from libcalamares.utils import gettext_path, gettext_languages + +import gettext +_translation = gettext.translation("calamares-python", + localedir=gettext_path(), + languages=gettext_languages(), + fallback=True) +_ = _translation.gettext +_n = _translation.ngettext + + +total_packages = 0 # For the entire job +completed_packages = 0 # Done so far for this job +group_packages = 0 # One group of packages from an -install or -remove entry + +# A PM object may set this to a string (take care of translations!) +# to override the string produced by pretty_status_message() +custom_status_message = None + +INSTALL = object() +REMOVE = object() +mode_packages = None # Changes to INSTALL or REMOVE + + +def _change_mode(mode): + global mode_packages + mode_packages = mode + libcalamares.job.setprogress(completed_packages * 1.0 / total_packages) + + +def pretty_name(): + return _("Install packages.") + + +def pretty_status_message(): + if custom_status_message is not None: + return custom_status_message + if not group_packages: + if (total_packages > 0): + # Outside the context of an operation + s = _("Processing packages (%(count)d / %(total)d)") + else: + s = _("Install packages.") + + elif mode_packages is INSTALL: + s = _n("Installing one package.", + "Installing %(num)d packages.", group_packages) + elif mode_packages is REMOVE: + s = _n("Removing one package.", + "Removing %(num)d packages.", group_packages) + else: + # No mode, generic description + s = _("Install packages.") + + return s % {"num": group_packages, + "count": completed_packages, + "total": total_packages} + + +class PackageManager(metaclass=abc.ABCMeta): + """ + Package manager base class. A subclass implements package management + for a specific backend, and must have a class property `backend` + with the string identifier for that backend. + + Subclasses are collected below to populate the list of possible + backends. + """ + backend = None + + @abc.abstractmethod + def install(self, pkgs, from_local=False): + """ + Install a list of packages (named) into the system. + Although this handles lists, in practice it is called + with one package at a time. + + @param pkgs: list[str] + list of package names + @param from_local: bool + if True, then these are local packages (on disk) and the + pkgs names are paths. + """ + pass + + @abc.abstractmethod + def remove(self, pkgs): + """ + Removes packages. + + @param pkgs: list[str] + list of package names + """ + pass + + @abc.abstractmethod + def update_db(self): + pass + + def run(self, script): + if script != "": + check_target_env_call(script.split(" ")) + + def install_package(self, packagedata, from_local=False): + """ + Install a package from a single entry in the install list. + This can be either a single package name, or an object + with pre- and post-scripts. If @p packagedata is a dict, + it is assumed to follow the documented structure. + + @param packagedata: str|dict + @param from_local: bool + see install.from_local + """ + if isinstance(packagedata, str): + self.install([packagedata], from_local=from_local) + else: + self.run(packagedata["pre-script"]) + self.install([packagedata["package"]], from_local=from_local) + self.run(packagedata["post-script"]) + + def remove_package(self, packagedata): + """ + Remove a package from a single entry in the remove list. + This can be either a single package name, or an object + with pre- and post-scripts. If @p packagedata is a dict, + it is assumed to follow the documented structure. + + @param packagedata: str|dict + """ + if isinstance(packagedata, str): + self.remove([packagedata]) + else: + self.run(packagedata["pre-script"]) + self.remove([packagedata["package"]]) + self.run(packagedata["post-script"]) + + def operation_install(self, package_list, from_local=False): + """ + Installs the list of packages named in @p package_list . + These can be strings -- plain package names -- or + structures (with a pre- and post-install step). + + This operation is called for "critical" packages, + which are expected to succeed, or fail, all together. + However, if there are packages with pre- or post-scripts, + then packages are installed one-by-one instead. + + NOTE: package managers may reimplement this method + NOTE: exceptions are expected to leave this method, to indicate + failure of the installation. + """ + if all([isinstance(x, str) for x in package_list]): + self.install(package_list, from_local=from_local) + else: + for package in package_list: + self.install_package(package, from_local=from_local) + + def operation_try_install(self, package_list): + """ + Installs the list of packages named in @p package_list . + These can be strings -- plain package names -- or + structures (with a pre- and post-install step). + + This operation is called for "non-critical" packages, + which can succeed or fail without affecting the overall installation. + Packages are installed one-by-one to support package managers + that do not have a "install as much as you can" mode. + + NOTE: package managers may reimplement this method + NOTE: no package-installation exceptions should be raised + """ + # we make a separate package manager call for each package so a + # single failing package won't stop all of them + for package in package_list: + try: + self.install_package(package) + except subprocess.CalledProcessError: + libcalamares.utils.warning("Could not install package %s" % package) + + def operation_remove(self, package_list): + """ + Removes the list of packages named in @p package_list . + These can be strings -- plain package names -- or + structures (with a pre- and post-install step). + + This operation is called for "critical" packages, which are + expected to succeed or fail all together. + However, if there are packages with pre- or post-scripts, + then packages are removed one-by-one instead. + + NOTE: package managers may reimplement this method + NOTE: exceptions should be raised to indicate failure + """ + if all([isinstance(x, str) for x in package_list]): + self.remove(package_list) + else: + for package in package_list: + self.remove_package(package) + + def operation_try_remove(self, package_list): + """ + Same relation as try_install has to install, except it removes + packages instead. Packages are removed one-by-one. + + NOTE: package managers may reimplement this method + NOTE: no package-installation exceptions should be raised + """ + for package in package_list: + try: + self.remove_package(package) + except subprocess.CalledProcessError: + libcalamares.utils.warning("Could not remove package %s" % package) + +### PACKAGE MANAGER IMPLEMENTATIONS +# +# Keep these alphabetical (presumably both by class name and backend name), +# even the Dummy implementation. +# + +class PMApk(PackageManager): + backend = "apk" + + def install(self, pkgs, from_local=False): + for pkg in pkgs: + check_target_env_call(["apk", "add", pkg]) + + def remove(self, pkgs): + for pkg in pkgs: + check_target_env_call(["apk", "del", pkg]) + + def update_db(self): + check_target_env_call(["apk", "update"]) + + def update_system(self): + check_target_env_call(["apk", "upgrade", "--available"]) + + +class PMApt(PackageManager): + backend = "apt" + + def install(self, pkgs, from_local=False): + check_target_env_call(["apt-get", "-q", "-y", "install"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["apt-get", "--purge", "-q", "-y", + "remove"] + pkgs) + check_target_env_call(["apt-get", "--purge", "-q", "-y", + "autoremove"]) + + def update_db(self): + check_target_env_call(["apt-get", "update"]) + + def update_system(self): + # Doesn't need to update the system explicitly + pass + + +class PMDnf(PackageManager): + """ + This is "legacy" DNF, called DNF-4 even though the + executable is dnf-3 in modern Fedora. Executable dnf + is a symlink to dnf-3 in systems that use it. + """ + backend = "dnf" + + def install(self, pkgs, from_local=False): + check_target_env_call(["dnf-3", "-y", "install"] + pkgs) + + def remove(self, pkgs): + # ignore the error code for now because dnf thinks removing a + # nonexistent package is an error + target_env_call(["dnf-3", "--disablerepo=*", "-C", "-y", + "remove"] + pkgs) + + def update_db(self): + # Doesn't need updates + pass + + def update_system(self): + check_target_env_call(["dnf-3", "-y", "upgrade"]) + + +class PMDnf5(PackageManager): + """ + This is "modern" DNF, DNF-5 which is for Fedora 41 (presumably) + and later. Executable dnf is a symlink to dnf5 in systems that use it. + """ + backend = "dnf5" + + def install(self, pkgs, from_local=False): + check_target_env_call(["dnf5", "-y", "install"] + pkgs) + + def remove(self, pkgs): + # ignore the error code for now because dnf thinks removing a + # nonexistent package is an error + target_env_call(["dnf5", "--disablerepo=*", "-C", "-y", + "remove"] + pkgs) + + def update_db(self): + # Doesn't need updates + pass + + def update_system(self): + check_target_env_call(["dnf5", "-y", "upgrade"]) + + +class PMDummy(PackageManager): + backend = "dummy" + + def install(self, pkgs, from_local=False): + from time import sleep + libcalamares.utils.debug("Dummy backend: Installing " + str(pkgs)) + sleep(3) + + def remove(self, pkgs): + from time import sleep + libcalamares.utils.debug("Dummy backend: Removing " + str(pkgs)) + sleep(3) + + def update_db(self): + libcalamares.utils.debug("Dummy backend: Updating DB") + + def update_system(self): + libcalamares.utils.debug("Dummy backend: Updating System") + + def run(self, script): + libcalamares.utils.debug("Dummy backend: Running script '" + str(script) + "'") + + +class PMEntropy(PackageManager): + backend = "entropy" + + def install(self, pkgs, from_local=False): + check_target_env_call(["equo", "i"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["equo", "rm"] + pkgs) + + def update_db(self): + check_target_env_call(["equo", "update"]) + + def update_system(self): + # Doesn't need to update the system explicitly + pass + +class PMFlatpak(PackageManager): + backend = "flatpak" + + def install(self, pkgs, from_local=False): + check_target_env_call(["flatpak", "install", "--assumeyes"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["flatpak", "uninstall", "--noninteractive"] + pkgs) + + def update_db(self): + pass + + def update_system(self): + # Doesn't need to update the system explicitly + pass + +class PMLuet(PackageManager): + backend = "luet" + + def install(self, pkgs, from_local=False): + check_target_env_call(["luet", "install", "-y"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["luet", "uninstall", "-y"] + pkgs) + + def update_db(self): + # Luet checks for DB update everytime its ran. + pass + + def update_system(self): + check_target_env_call(["luet", "upgrade", "-y"]) + + +class PMPackageKit(PackageManager): + backend = "packagekit" + + def install(self, pkgs, from_local=False): + for pkg in pkgs: + check_target_env_call(["pkcon", "-py", "install", pkg]) + + def remove(self, pkgs): + for pkg in pkgs: + check_target_env_call(["pkcon", "-py", "remove", pkg]) + + def update_db(self): + check_target_env_call(["pkcon", "refresh"]) + + def update_system(self): + check_target_env_call(["pkcon", "-py", "update"]) + + +class PMPacman(PackageManager): + backend = "pacman" + + def __init__(self): + import re + progress_match = re.compile("^\\((\\d+)/(\\d+)\\)") + + def line_cb(line): + if line.startswith(":: "): + self.in_package_changes = "package" in line or "hooks" in line + else: + if self.in_package_changes and line.endswith("...\n"): + # Update the message, untranslated; do not change the + # progress percentage, since there may be more "installing..." + # lines in the output for the group, than packages listed + # explicitly. We don't know how to calculate proper progress. + global custom_status_message + custom_status_message = "pacman: " + line.strip() + libcalamares.job.setprogress(self.progress_fraction) + libcalamares.utils.debug(line) + + self.in_package_changes = False + self.line_cb = line_cb + + pacman = libcalamares.job.configuration.get("pacman", None) + if pacman is None: + pacman = dict() + if type(pacman) is not dict: + libcalamares.utils.warning("Job configuration *pacman* will be ignored.") + pacman = dict() + self.pacman_num_retries = pacman.get("num_retries", 0) + self.pacman_disable_timeout = pacman.get("disable_download_timeout", False) + self.pacman_needed_only = pacman.get("needed_only", False) + + def reset_progress(self): + self.in_package_changes = False + # These are globals + self.progress_fraction = (completed_packages * 1.0 / total_packages) + + def run_pacman(self, command, callback=False): + """ + Call pacman in a loop until it is successful or the number of retries is exceeded + :param command: The pacman command to run + :param callback: An optional boolean that indicates if this pacman run should use the callback + :return: + """ + + pacman_count = 0 + while pacman_count <= self.pacman_num_retries: + pacman_count += 1 + try: + if False: # callback: + libcalamares.utils.target_env_process_output(command, self.line_cb) + else: + libcalamares.utils.target_env_process_output(command) + + return + except subprocess.CalledProcessError: + if pacman_count <= self.pacman_num_retries: + pass + else: + raise + + def install(self, pkgs, from_local=False): + command = ["pacman"] + + if from_local: + command.append("-U") + else: + command.append("-S") + + # Don't ask for user intervention, take the default action + command.append("--noconfirm") + + # Don't report download progress for each file + command.append("--noprogressbar") + + if self.pacman_needed_only is True: + command.append("--needed") + command.append("--ask=4") + + if self.pacman_disable_timeout is True: + command.append("--disable-download-timeout") + + command += pkgs + + self.reset_progress() + self.run_pacman(command, True) + + def remove(self, pkgs): + self.reset_progress() + self.run_pacman(["pacman", "-Rs", "--noconfirm"] + pkgs, True) + + def update_db(self): + self.run_pacman(["pacman", "-Sy"]) + + def update_system(self): + self.run_pacman(["pacman", "-S", "archlinux-keyring", "--noconfirm"]) + command = ["pacman", "-Su", "--noconfirm"] + if self.pacman_disable_timeout is True: + command.append("--disable-download-timeout") + + self.run_pacman(command) + + +class PMPamac(PackageManager): + backend = "pamac" + + def del_db_lock(self, lock="/var/lib/pacman/db.lck"): + # In case some error or crash, the database will be locked, + # resulting in remaining packages not being installed. + check_target_env_call(["rm", "-f", lock]) + + def install(self, pkgs, from_local=False): + self.del_db_lock() + check_target_env_call([self.backend, "install", "--no-confirm"] + pkgs) + + def remove(self, pkgs): + self.del_db_lock() + check_target_env_call([self.backend, "remove", "--no-confirm"] + pkgs) + + def update_db(self): + self.del_db_lock() + check_target_env_call([self.backend, "update", "--no-confirm"]) + + def update_system(self): + self.del_db_lock() + check_target_env_call([self.backend, "upgrade", "--no-confirm"]) + + +class PMPisi(PackageManager): + backend = "pisi" + + def install(self, pkgs, from_local=False): + check_target_env_call(["pisi", "install" "-y"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["pisi", "remove", "-y"] + pkgs) + + def update_db(self): + check_target_env_call(["pisi", "update-repo"]) + + def update_system(self): + # Doesn't need to update the system explicitly + pass + + +class PMPortage(PackageManager): + backend = "portage" + + def install(self, pkgs, from_local=False): + check_target_env_call(["emerge", "-v"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["emerge", "-C"] + pkgs) + check_target_env_call(["emerge", "--depclean", "-q"]) + + def update_db(self): + check_target_env_call(["emerge", "--sync"]) + + def update_system(self): + # Doesn't need to update the system explicitly + pass + + +class PMXbps(PackageManager): + backend = "xbps" + + def install(self, pkgs, from_local=False): + check_target_env_call(["xbps-install", "-Sy"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["xbps-remove", "-Ry", "--noconfirm"] + pkgs) + + def update_db(self): + check_target_env_call(["xbps-install", "-S"]) + + def update_system(self): + check_target_env_call(["xbps", "-Suy"]) + + +class PMYum(PackageManager): + backend = "yum" + + def install(self, pkgs, from_local=False): + check_target_env_call(["yum", "-y", "install"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["yum", "--disablerepo=*", "-C", "-y", + "remove"] + pkgs) + + def update_db(self): + # Doesn't need updates + pass + + def update_system(self): + check_target_env_call(["yum", "-y", "upgrade"]) + + +class PMZypp(PackageManager): + backend = "zypp" + + def install(self, pkgs, from_local=False): + check_target_env_call(["zypper", "--non-interactive", + "--quiet-install", "install", + "--auto-agree-with-licenses", + "install"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["zypper", "--non-interactive", + "remove"] + pkgs) + + def update_db(self): + check_target_env_call(["zypper", "--non-interactive", "update"]) + + def update_system(self): + # Doesn't need to update the system explicitly + pass + + +# Collect all the subclasses of PackageManager defined above, +# and index them based on the backend property of each class. +backend_managers = [ + (c.backend, c) + for c in globals().values() + if type(c) is abc.ABCMeta and issubclass(c, PackageManager) and c.backend] + + +def subst_locale(plist): + """ + Returns a locale-aware list of packages, based on @p plist. + Package names that contain LOCALE are localized with the + BCP47 name of the chosen system locale; if the system + locale is 'en' (e.g. English, US) then these localized + packages are dropped from the list. + + @param plist: list[str|dict] + Candidate packages to install. + @return: list[str|dict] + """ + locale = libcalamares.globalstorage.value("locale") + if not locale: + # It is possible to skip the locale-setting entirely. + # Then pretend it is "en", so that {LOCALE}-decorated + # package names are removed from the list. + locale = "en" + + ret = [] + for packagedata in plist: + if isinstance(packagedata, str): + packagename = packagedata + else: + packagename = packagedata["package"] + + # Update packagename: substitute LOCALE, and drop packages + # if locale is en and LOCALE is in the package name. + if locale != "en": + packagename = Template(packagename).safe_substitute(LOCALE=locale) + elif 'LOCALE' in packagename: + packagename = None + + if packagename is not None: + # Put it back in packagedata + if isinstance(packagedata, str): + packagedata = packagename + else: + packagedata["package"] = packagename + + ret.append(packagedata) + + return ret + + +def run_operations(pkgman, entry): + """ + Call package manager with suitable parameters for the given + package actions. + + :param pkgman: PackageManager + This is the manager that does the actual work. + :param entry: dict + Keys are the actions -- e.g. "install" -- to take, and the values + are the (list of) packages to apply the action to. The actions are + not iterated in a specific order, so it is recommended to use only + one action per dictionary. The list of packages may be package + names (strings) or package information dictionaries with pre- + and post-scripts. + """ + global group_packages, completed_packages, mode_packages + + for key in entry.keys(): + package_list = subst_locale(entry[key]) + group_packages = len(package_list) + if key == "install": + _change_mode(INSTALL) + pkgman.operation_install(package_list) + elif key == "try_install": + _change_mode(INSTALL) + pkgman.operation_try_install(package_list) + elif key == "remove": + _change_mode(REMOVE) + pkgman.operation_remove(package_list) + elif key == "try_remove": + _change_mode(REMOVE) + pkgman.operation_try_remove(package_list) + elif key == "localInstall": + _change_mode(INSTALL) + pkgman.operation_install(package_list, from_local=True) + elif key == "source": + libcalamares.utils.debug("Package-list from {!s}".format(entry[key])) + else: + libcalamares.utils.warning("Unknown package-operation key {!s}".format(key)) + completed_packages += len(package_list) + libcalamares.job.setprogress(completed_packages * 1.0 / total_packages) + libcalamares.utils.debug("Pretty name: {!s}, setting progress..".format(pretty_name())) + + group_packages = 0 + _change_mode(None) + + +def run(): + """ + Calls routine with detected package manager to install locale packages + or remove drivers not needed on the installed system. + + :return: + """ + global mode_packages, total_packages, completed_packages, group_packages + + backend = libcalamares.job.configuration.get("backend") + + for identifier, impl in backend_managers: + if identifier == backend: + pkgman = impl() + break + else: + return "Bad backend", "backend=\"{}\"".format(backend) + + skip_this = libcalamares.job.configuration.get("skip_if_no_internet", False) + if skip_this and not libcalamares.globalstorage.value("hasInternet"): + libcalamares.utils.warning( "Package installation has been skipped: no internet" ) + return None + + update_db = libcalamares.job.configuration.get("update_db", False) + if update_db and libcalamares.globalstorage.value("hasInternet"): + try: + pkgman.update_db() + except subprocess.CalledProcessError as e: + libcalamares.utils.warning(str(e)) + libcalamares.utils.debug("stdout:" + str(e.stdout)) + libcalamares.utils.debug("stderr:" + str(e.stderr)) + return (_("Package Manager error"), + _("The package manager could not prepare updates. The command
{!s}
returned error code {!s}.") + .format(e.cmd, e.returncode)) + + update_system = libcalamares.job.configuration.get("update_system", False) + if update_system and libcalamares.globalstorage.value("hasInternet"): + try: + pkgman.update_system() + except subprocess.CalledProcessError as e: + libcalamares.utils.warning(str(e)) + libcalamares.utils.debug("stdout:" + str(e.stdout)) + libcalamares.utils.debug("stderr:" + str(e.stderr)) + return (_("Package Manager error"), + _("The package manager could not update the system. The command
{!s}
returned error code {!s}.") + .format(e.cmd, e.returncode)) + + operations = libcalamares.job.configuration.get("operations", []) + if libcalamares.globalstorage.contains("packageOperations"): + operations += libcalamares.globalstorage.value("packageOperations") + + mode_packages = None + total_packages = 0 + completed_packages = 0 + for op in operations: + for packagelist in op.values(): + total_packages += len(subst_locale(packagelist)) + + if not total_packages: + # Avoids potential divide-by-zero in progress reporting + return None + + for entry in operations: + group_packages = 0 + libcalamares.utils.debug(pretty_name()) + try: + run_operations(pkgman, entry) + except subprocess.CalledProcessError as e: + libcalamares.utils.warning(str(e)) + libcalamares.utils.debug("stdout:" + str(e.stdout)) + libcalamares.utils.debug("stderr:" + str(e.stderr)) + return (_("Package Manager error"), + _("The package manager could not make changes to the installed system. The command
{!s}
returned error code {!s}.") + .format(e.cmd, e.returncode)) + + mode_packages = None + + libcalamares.job.setprogress(1.0) + + return None diff --git a/snigdhaos-calamares-config/modules/packages/module.desc b/snigdhaos-calamares-config/modules/packages/module.desc new file mode 100644 index 00000000..3e3053bf --- /dev/null +++ b/snigdhaos-calamares-config/modules/packages/module.desc @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "packages" +interface: "python" +script: "main.py" diff --git a/snigdhaos-calamares-config/modules/packages/packages.schema.yaml b/snigdhaos-calamares-config/modules/packages/packages.schema.yaml new file mode 100644 index 00000000..3e3b5163 --- /dev/null +++ b/snigdhaos-calamares-config/modules/packages/packages.schema.yaml @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/packages +additionalProperties: false +type: object +properties: + backend: + type: string + enum: + - apk + - apt + - dnf + - dnf5 + - entropy + - luet + - packagekit + - pacman + - pamac + - portage + - yum + - zypp + - dummy + + update_db: { type: boolean, default: true } + update_system: { type: boolean, default: false } + skip_if_no_internet: { type: boolean, default: false } + + pacman: + additionalProperties: false + type: object + properties: + num_retries: { type: integer, default: 0 } + disable_download_timeout: { type: boolean, default: false } + needed_only: { type: boolean, default: false } + + operations: + type: array + items: + additionalProperties: false + type: object + properties: + # TODO: these are either-string-or-struct items, + # need their own little schema. + install: { type: array } + remove: { type: array } + try_install: { type: array } + try_remove: { type: array } + localInstall: { type: array } + source: { type: string } + + +required: [ backend ] diff --git a/snigdhaos-calamares-config/modules/packages/tests/1.global b/snigdhaos-calamares-config/modules/packages/tests/1.global new file mode 100644 index 00000000..ee06ccfe --- /dev/null +++ b/snigdhaos-calamares-config/modules/packages/tests/1.global @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +rootMountPoint: /tmp diff --git a/snigdhaos-calamares-config/modules/packages/tests/2.job b/snigdhaos-calamares-config/modules/packages/tests/2.job new file mode 100644 index 00000000..ba205ed4 --- /dev/null +++ b/snigdhaos-calamares-config/modules/packages/tests/2.job @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +backend: dummy +operations: + - install: + - pre-script: touch /tmp/foo + package: vi + post-script: rm /tmp/foo + - wget + - binutils + - remove: + - vi + - wget diff --git a/snigdhaos-calamares-config/modules/packages/tests/CMakeTests.txt b/snigdhaos-calamares-config/modules/packages/tests/CMakeTests.txt new file mode 100644 index 00000000..66da86b5 --- /dev/null +++ b/snigdhaos-calamares-config/modules/packages/tests/CMakeTests.txt @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# We have tests to load (some) of the package-managers specifically, to +# test their configuration code and implementation. Those tests conventionally +# live in Python files here in the tests/ directory. Add them. + +# Pacman (Arch) tests +set(_pm pacman) +add_test( + NAME configure-packages-${_pm} + COMMAND env PYTHONPATH=.: python3 ${CMAKE_CURRENT_LIST_DIR}/test-pm-${_pm}.py + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} +) +add_test( + NAME configure-packages-${_pm}-ops-1 + COMMAND + env PYTHONPATH=.: python3 ${CMAKE_CURRENT_LIST_DIR}/test-pm-${_pm}.py ${CMAKE_CURRENT_LIST_DIR}/pm-pacman-1.yaml + 4 1 1 + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} +) +add_test( + NAME configure-packages-${_pm}-ops-2 + COMMAND + env PYTHONPATH=.: python3 ${CMAKE_CURRENT_LIST_DIR}/test-pm-${_pm}.py ${CMAKE_CURRENT_LIST_DIR}/pm-pacman-2.yaml + 3 0 0 + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} +) + +if(BUILD_TESTING AND BUILD_SCHEMA_TESTING AND Python_Interpreter_FOUND) + set(_module packages) + set(_schema_file "${CMAKE_CURRENT_SOURCE_DIR}/${_module}/${_module}.schema.yaml") + message(STATUS "Schema ${_schema_file}") + foreach(_cf pm-pacman-1.yaml pm-pacman-2.yaml) + set(_conf_file "${CMAKE_CURRENT_SOURCE_DIR}/${_module}/tests/${_cf}") + if(EXISTS "${_schema_file}" AND EXISTS "${_conf_file}") + add_test( + NAME validate-packages-${_cf} + COMMAND + ${Python_EXECUTABLE} "${CMAKE_SOURCE_DIR}/ci/configvalidator.py" "${_schema_file}" "${_conf_file}" + ) + else() + message(FATAL_ERROR "Missing ${_conf_file}") + endif() + endforeach() +endif() diff --git a/snigdhaos-calamares-config/modules/packages/tests/pm-pacman-1.yaml b/snigdhaos-calamares-config/modules/packages/tests/pm-pacman-1.yaml new file mode 100644 index 00000000..aeb5b862 --- /dev/null +++ b/snigdhaos-calamares-config/modules/packages/tests/pm-pacman-1.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +backend: pacman +operations: [] + +pacman: + num_retries: 4 + disable_download_timeout: true + needed_only: true + diff --git a/snigdhaos-calamares-config/modules/packages/tests/pm-pacman-2.yaml b/snigdhaos-calamares-config/modules/packages/tests/pm-pacman-2.yaml new file mode 100644 index 00000000..8b0bda39 --- /dev/null +++ b/snigdhaos-calamares-config/modules/packages/tests/pm-pacman-2.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +backend: pacman +operations: [] + +# Leave some things unspecified +pacman: + num_retries: 3 + diff --git a/snigdhaos-calamares-config/modules/packages/tests/test-pm-pacman.py b/snigdhaos-calamares-config/modules/packages/tests/test-pm-pacman.py new file mode 100644 index 00000000..ee814b62 --- /dev/null +++ b/snigdhaos-calamares-config/modules/packages/tests/test-pm-pacman.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Calamares Boilerplate +import libcalamares +libcalamares.globalstorage = libcalamares.GlobalStorage(None) +libcalamares.globalstorage.insert("testing", True) + +# Module prep-work +from src.modules.packages import main + +# .. we don't have a job in this test, so fake one +class Job(object): + def __init__(self, filename): + self.configuration = libcalamares.utils.load_yaml(filename) if filename is not None else dict() + +import sys +if len(sys.argv) > 4: + filename = sys.argv[1] + retry = int(sys.argv[2]) + timeout = bool(int(sys.argv[3])) + needed = bool(int(sys.argv[4])) +else: + filename = None + retry = 0 + timeout = False + needed = False + +libcalamares.utils.warning("Expecting {!s} retry={!s} timeout={!s} needed={!s}".format(filename, retry, timeout, needed)) + +# Specific PM test +libcalamares.job = Job(filename) +p = main.PMPacman() +assert p.pacman_num_retries == retry, "{!r} vs {!r}".format(p.pacman_num_retries, retry) +assert p.pacman_disable_timeout == timeout, "{!r} vs {!r}".format(p.pacman_disable_timeout, timeout) +assert p.pacman_needed_only == needed, "{!r} vs {!r}".format(p.pacman_needed_only, needed) diff --git a/snigdhaos-calamares-config/modules/ucode/main.py b/snigdhaos-calamares-config/modules/ucode/main.py new file mode 100644 index 00000000..56f87a88 --- /dev/null +++ b/snigdhaos-calamares-config/modules/ucode/main.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# Copyright 2014 - 2019, Philip Müller +# Copyright 2016, Artoo +# +# Calamares is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Calamares is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Calamares. If not, see . + +import libcalamares +import subprocess + +from shutil import copy2 +from distutils.dir_util import copy_tree +from os.path import join, exists +from libcalamares.utils import target_env_call + + +class ConfigController: + def __init__(self): + self.__root = libcalamares.globalstorage.value("rootMountPoint") + self.__keyrings = libcalamares.job.configuration.get('keyrings', []) + + @property + def root(self): + return self.__root + + @property + def keyrings(self): + return self.__keyrings + + def init_keyring(self): + target_env_call(["pacman-key", "--init"]) + + def populate_keyring(self): + target_env_call(["pacman-key", "--populate"] + self.keyrings) + + def terminate(self, proc): + target_env_call(['killall', '-9', proc]) + + def copy_file(self, file): + if exists("/" + file): + copy2("/" + file, join(self.root, file)) + + def copy_folder(self, source, target): + if exists("/" + source): + copy_tree("/" + source, join(self.root, target)) + + def remove_pkg(self, pkg, path): + if exists(join(self.root, path)): + target_env_call(['pacman', '-R', '--noconfirm', pkg]) + + def umount(self, mp): + subprocess.call(["umount", "-l", join(self.root, mp)]) + + def mount(self, mp): + subprocess.call(["mount", "-B", "/" + mp, join(self.root, mp)]) + + def rmdir(self, dir): + subprocess.call(["rm", "-Rf", join(self.root, dir)]) + + def mkdir(self, dir): + subprocess.call(["mkdir", "-p", join(self.root, dir)]) + + def run(self): + + # ucode + cpu_ucode = subprocess.getoutput("hwinfo --cpu | grep Vendor: -m1 | cut -d\'\"\' -f2") + if cpu_ucode == "AuthenticAMD": + #target_env_call(["pacman", "-S", "amd-ucode", "--noconfirm"]) + target_env_call(["pacman", "-R", "intel-ucode", "--noconfirm"]) + elif cpu_ucode == "GenuineIntel": + #target_env_call(["pacman", "-S", "intel-ucode", "--noconfirm"]) + target_env_call(["pacman", "-R", "amd-ucode", "--noconfirm"]) + + if exists(join(self.root, "usr/bin/snapper")): + target_env_call(["snapper", "-c", "root", "--no-dbus", "create-config", "/"]) + target_env_call(["btrfs", "subvolume", "create", "/.snapshots"]) + target_env_call(["chmod", "a+rx", "/.snapshots"]) + target_env_call(["chown", ":users", "/.snapshots"]) + +def run(): + """ Post installation configurations """ + + config = ConfigController() + + return config.run() diff --git a/snigdhaos-calamares-config/modules/ucode/module.desc b/snigdhaos-calamares-config/modules/ucode/module.desc new file mode 100644 index 00000000..0591a6b9 --- /dev/null +++ b/snigdhaos-calamares-config/modules/ucode/module.desc @@ -0,0 +1,6 @@ +# Syntax is YAML 1.2 +--- +type: "job" +name: "ucode" +interface: "python" +script: "main.py" #assumed relative to the current directory