From 02007a29ba593ec84bdabeda86b7e4c8c172339c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Exp=C3=B3sito?= Date: Thu, 8 Feb 2024 10:26:50 +0100 Subject: [PATCH 01/31] Add GNOME Kiosk keyboard manager class Add a class wrapping GNOME Kiosk's input sources API that future commits will use via the localization service to replace the X11 keyboard management logic. Resolves: RHEL-38399 (cherry picked from commit 805826c355c476490dfddedb7e3c0078d23ec84e) --- .../modules/common/constants/services.py | 9 +- .../localization/gk_keyboard_manager.py | 135 ++++++++++++++++++ .../modules/localization/localization.py | 33 +++++ .../localization/localization_interface.py | 58 +++++++- 4 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 pyanaconda/modules/localization/gk_keyboard_manager.py diff --git a/pyanaconda/modules/common/constants/services.py b/pyanaconda/modules/common/constants/services.py index 468d4ef7684..afcd3e94690 100644 --- a/pyanaconda/modules/common/constants/services.py +++ b/pyanaconda/modules/common/constants/services.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from pyanaconda.core.dbus import SystemBus, DBus +from pyanaconda.core.dbus import SystemBus, SessionBus, DBus from dasbus.identifier import DBusServiceIdentifier from pyanaconda.modules.common.constants.namespaces import BOSS_NAMESPACE, TIMEZONE_NAMESPACE, \ NETWORK_NAMESPACE, LOCALIZATION_NAMESPACE, SECURITY_NAMESPACE, USERS_NAMESPACE, \ @@ -107,3 +107,10 @@ namespace=NETWORK_MANAGER_NAMESPACE, message_bus=SystemBus ) + +# Session services. + +GK_INPUT_SOURCES = DBusServiceIdentifier( + namespace=("org", "gnome", "Kiosk"), + message_bus=SessionBus +) diff --git a/pyanaconda/modules/localization/gk_keyboard_manager.py b/pyanaconda/modules/localization/gk_keyboard_manager.py new file mode 100644 index 00000000000..8a68e59990f --- /dev/null +++ b/pyanaconda/modules/localization/gk_keyboard_manager.py @@ -0,0 +1,135 @@ +# +# Copyright (C) 2024 Red Hat, Inc. +# +# This copyrighted material is made available to anyone wishing to use, +# modify, copy, or redistribute it subject to the terms and conditions of +# the GNU General Public License v.2, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY expressed or implied, including the implied warranties 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 this program; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. Any Red Hat trademarks that are incorporated in the +# source code or documentation are not subject to the GNU General Public +# License and may only be used or replicated with the express permission of +# Red Hat, Inc. +# + +from pyanaconda.core.signal import Signal +from pyanaconda.keyboard import join_layout_variant, parse_layout_variant, KeyboardConfigError +from pyanaconda.modules.common.constants.services import GK_INPUT_SOURCES + + +class GkKeyboardManager(object): + """Class wrapping GNOME Kiosk's input sources API.""" + + def __init__(self): + self.compositor_selected_layout_changed = Signal() + self.compositor_layouts_changed = Signal() + + object_path = GK_INPUT_SOURCES.object_path + '/InputSources/Manager' + self._proxy = GK_INPUT_SOURCES.get_proxy(object_path=object_path) + self._proxy.PropertiesChanged.connect(self._on_properties_changed) + + def _on_properties_changed(self, interface, changed_props, invalid_props): + for prop in changed_props: + if prop == 'SelectedInputSource': + layout_path = changed_props[prop] + layout_variant = self._path_to_layout(layout_path.get_string()) + self.compositor_selected_layout_changed.emit(layout_variant) + if prop == 'InputSources': + layout_paths = changed_props[prop] + layout_variants = map(self._path_to_layout, list(layout_paths)) + self.compositor_layouts_changed.emit(list(layout_variants)) + + def _path_to_layout(self, layout_path): + """Transforms a layout path as returned by GNOME Kiosk to "layout (variant)". + + :param layout_path: D-Bus path to the layout. + (e.g. "/org/gnome/Kiosk/InputSources/xkb_cz_2b_mon_5f_todo_5f_galik") + :type layout_path: str + :return: The layout with format "layout (variant)" (e.g. "cn (mon_todo_galik)") + :rtype: str + + :raise KeyboardConfigError: if layouts with invalid backend type is found + """ + layout_proxy = GK_INPUT_SOURCES.get_proxy(object_path=layout_path) + + if layout_proxy.BackendType != 'xkb': + raise KeyboardConfigError('Failed to get configuration from compositor') + + if '+' in layout_proxy.BackendId: + layout, variant = layout_proxy.BackendId.split('+') + return join_layout_variant(layout, variant) + else: + return layout_proxy.BackendId + + def _layout_to_xkb(self, layout_variant): + """Transforms a "layout (variant)" to a "('xkb', 'layout+variant')". + + :param layout_variant: The layout with format "layout (variant)" (e.g. "cz (qwerty)") + :type layout_variant: str + :return: The layout with format "('xkb', 'layout+variant')" (e.g. "('xkb', 'cz+qwerty')") + :rtype: str + """ + layout, variant = parse_layout_variant(layout_variant) + if variant: + return ('xkb', '{0}+{1}'.format(layout, variant)) + else: + return ('xkb', layout) + + def get_compositor_selected_layout(self): + """Get the activated keyboard layout. + + :return: Current keyboard layout (e.g. "cz (qwerty)") + :rtype: str + """ + layout_path = self._proxy.SelectedInputSource + if not layout_path or layout_path == '/': + return '' + + return self._path_to_layout(layout_path) + + def set_compositor_selected_layout(self, layout_variant): + """Set the activated keyboard layout. + + :param layout_variant: The layout to set, with format "layout (variant)" + (e.g. "cz (qwerty)") + :type layout_variant: str + :return: If the keyboard layout was activated + :rtype: bool + """ + layout_paths = self._proxy.InputSources + for layout_path in layout_paths: + if self._path_to_layout(layout_path) == layout_variant: + self._proxy.SelectInputSource(layout_path) + return True + + return False + + def select_next_compositor_layout(self): + """Set the next available layout as active.""" + self._proxy.SelectNextInputSource() + + def get_compositor_layouts(self): + """Get all available keyboard layouts. + + :return: A list of keyboard layouts (e.g. ["cz (qwerty)", cn (mon_todo_galik)]) + :rtype: list of strings + """ + layout_paths = self._proxy.InputSources + layout_variants = map(self._path_to_layout, list(layout_paths)) + return list(layout_variants) + + def set_compositor_layouts(self, layout_variants, options): + """Set the available keyboard layouts. + + :param layout_variants: A list of keyboard layouts (e.g. ["cz (qwerty)", + cn (mon_todo_galik)]) + :type layout_variants: list of strings + :param options: A list of switching options + :type options: list of strings + """ + xkb_layouts = list(map(self._layout_to_xkb, layout_variants)) + self._proxy.SetInputSources(xkb_layouts, options) diff --git a/pyanaconda/modules/localization/localization.py b/pyanaconda/modules/localization/localization.py index 80e93b676a2..452481e56ac 100644 --- a/pyanaconda/modules/localization/localization.py +++ b/pyanaconda/modules/localization/localization.py @@ -35,6 +35,7 @@ from pyanaconda.modules.localization.runtime import GetMissingKeyboardConfigurationTask, \ ApplyKeyboardTask, AssignGenericKeyboardSettingTask from pyanaconda.modules.localization.localed import LocaledWrapper +from pyanaconda.modules.localization.gk_keyboard_manager import GkKeyboardManager from pyanaconda.anaconda_loggers import get_module_logger log = get_module_logger(__name__) @@ -66,7 +67,11 @@ def __init__(self): self.keyboard_seen_changed = Signal() self._keyboard_seen = False + self.compositor_selected_layout_changed = Signal() + self.compositor_layouts_changed = Signal() + self._localed_wrapper = None + self._compositor_keyboard_manager = None def publish(self): """Publish the module.""" @@ -315,3 +320,31 @@ def set_from_generic_keyboard_setting(self, keyboard): ) result = task.run() self._update_settings_from_task(result) + + @property + def compositor_keyboard_manager(self): + if not self._compositor_keyboard_manager: + self._compositor_keyboard_manager = GkKeyboardManager() + self._compositor_keyboard_manager.compositor_selected_layout_changed.connect( + lambda layout: self.compositor_selected_layout_changed.emit(layout) + ) + self._compositor_keyboard_manager.compositor_layouts_changed.connect( + lambda layouts: self.compositor_layouts_changed.emit(layouts) + ) + + return self._compositor_keyboard_manager + + def get_compositor_selected_layout(self): + return self.compositor_keyboard_manager.get_compositor_selected_layout() + + def set_compositor_selected_layout(self, layout_variant): + return self.compositor_keyboard_manager.set_compositor_selected_layout(layout_variant) + + def select_next_compositor_layout(self): + return self.compositor_keyboard_manager.select_next_compositor_layout() + + def get_compositor_layouts(self): + return self.compositor_keyboard_manager.get_compositor_layouts() + + def set_compositor_layouts(self, layout_variants, options): + self.compositor_keyboard_manager.set_compositor_layouts(layout_variants, options) diff --git a/pyanaconda/modules/localization/localization_interface.py b/pyanaconda/modules/localization/localization_interface.py index 2e8d22a1550..4a299f2b7ce 100644 --- a/pyanaconda/modules/localization/localization_interface.py +++ b/pyanaconda/modules/localization/localization_interface.py @@ -24,7 +24,7 @@ from dasbus.typing import * # pylint: disable=wildcard-import from pyanaconda.modules.common.base import KickstartModuleInterface from pyanaconda.modules.common.containers import TaskContainer -from dasbus.server.interface import dbus_interface +from dasbus.server.interface import dbus_interface, dbus_signal @dbus_interface(LOCALIZATION.interface_name) @@ -40,6 +40,10 @@ def connect_signals(self): self.watch_property("XLayouts", self.implementation.x_layouts_changed) self.watch_property("LayoutSwitchOptions", self.implementation.switch_options_changed) self.watch_property("KeyboardKickstarted", self.implementation.keyboard_seen_changed) + self.implementation.compositor_selected_layout_changed.connect( + self.CompositorSelectedLayoutChanged + ) + self.implementation.compositor_layouts_changed.connect(self.CompositorLayoutsChanged) def GetLanguages(self) -> List[Str]: """Get languages with available translations. @@ -237,3 +241,55 @@ def ApplyKeyboardWithTask(self) -> ObjPath: return TaskContainer.to_object_path( self.implementation.apply_keyboard_with_task() ) + + def GetCompositorSelectedLayout(self) -> Str: + """Get the activated keyboard layout. + + :return: Current keyboard layout (e.g. "cz (qwerty)") + :rtype: str + """ + return self.implementation.get_compositor_selected_layout() + + def SetCompositorSelectedLayout(self, layout_variant: Str) -> Bool: + """Set the activated keyboard layout. + + :param layout_variant: The layout to set, with format "layout (variant)" + (e.g. "cz (qwerty)") + :type layout_variant: str + :return: If the keyboard layout was activated + :rtype: bool + """ + return self.implementation.set_compositor_selected_layout(layout_variant) + + def SelectNextCompositorLayout(self): + """Set the next available layout as active.""" + return self.implementation.select_next_compositor_layout() + + @dbus_signal + def CompositorSelectedLayoutChanged(self, layout: Str): + """Signal emitted when the selected keyboard layout changes.""" + pass + + def GetCompositorLayouts(self) -> List[Str]: + """Get all available keyboard layouts. + + :return: A list of keyboard layouts (e.g. ["cz (qwerty)", cn (mon_todo_galik)]) + :rtype: list of strings + """ + return self.implementation.get_compositor_layouts() + + def SetCompositorLayouts(self, layout_variants: List[Str], options: List[Str]): + """Set the available keyboard layouts. + + :param layout_variants: A list of keyboard layouts (e.g. ["cz (qwerty)", + cn (mon_todo_galik)]) + :type layout_variants: list of strings + :param options: A list of switching options + :type options: list of strings + """ + self.implementation.set_compositor_layouts(layout_variants, options) + + @dbus_signal + def CompositorLayoutsChanged(self, layouts: List[Str]): + """Signal emitted when available layouts change.""" + pass From f868873aec9c4efc3f75db573931a31575584047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Exp=C3=B3sito?= Date: Thu, 8 Feb 2024 11:01:01 +0100 Subject: [PATCH 02/31] Use GNOME Kiosk's API in XklWrapper libxklavier is deprecated and X11-only. On RHEL, the GNOME Kiosk API can be used instead to handle the keyboard configuration. In order to make the code migration as simple as possible, keep the XklWrapper class and update its implementation to use GNOME Kiosk's API via the localization service. Resolves: RHEL-38399 (cherry picked from commit 3a0088e10578e5b1499d72af58e683ce5d0442ad) --- pyanaconda/ui/gui/spokes/keyboard.py | 51 +++--- pyanaconda/ui/gui/xkl_wrapper.py | 224 +++++---------------------- 2 files changed, 70 insertions(+), 205 deletions(-) diff --git a/pyanaconda/ui/gui/spokes/keyboard.py b/pyanaconda/ui/gui/spokes/keyboard.py index f17095e3240..848d440cc39 100644 --- a/pyanaconda/ui/gui/spokes/keyboard.py +++ b/pyanaconda/ui/gui/spokes/keyboard.py @@ -24,7 +24,7 @@ from pyanaconda.ui.categories.localization import LocalizationCategory from pyanaconda.ui.gui.utils import gtk_call_once, escape_markup, gtk_batch_map, timed_action from pyanaconda.ui.gui.utils import override_cell_property -from pyanaconda.ui.gui.xkl_wrapper import XklWrapper, XklWrapperError +from pyanaconda.ui.gui.xkl_wrapper import XklWrapper from pyanaconda import keyboard from pyanaconda import flags from pyanaconda.core.i18n import _, N_, CN_ @@ -331,6 +331,12 @@ def __init__(self, *args): self._l12_module = LOCALIZATION.get_proxy() self._seen = self._l12_module.KeyboardKickstarted + self._compositor_initial_layout = self._xkl_wrapper.get_current_layout() + self._xkl_wrapper.compositor_selected_layout_changed.connect( + self._on_compositor_selected_layout_changed + ) + self._xkl_wrapper.compositor_layouts_changed.connect(self._on_compositor_layouts_changed) + def apply(self): # the user has confirmed (seen) the configuration self._confirmed = True @@ -363,7 +369,7 @@ def completed(self): # Request user attention if the current activated layout is a different from the # selected ones - return self._xkl_wrapper.get_current_layout() in self._l12_module.XLayouts + return self._compositor_initial_layout in self._l12_module.XLayouts @property def status(self): @@ -465,12 +471,12 @@ def refresh(self): self._refresh_switching_info() def _addLayout(self, store, name): - # first try to add the layout - if keyboard.can_configure_keyboard(): - self._xkl_wrapper.add_layout(name) + if not self._xkl_wrapper.is_valid_layout(name): + return False # valid layout, append it to the store store.append([name]) + return True def _removeLayout(self, store, itr): """ @@ -479,8 +485,6 @@ def _removeLayout(self, store, itr): """ - if keyboard.can_configure_keyboard(): - self._xkl_wrapper.remove_layout(store[itr][0]) store.remove(itr) def _refresh_switching_info(self): @@ -526,6 +530,9 @@ def on_add_clicked(self, button): # Update the selection information self._selection.emit("changed") + if keyboard.can_configure_keyboard(): + self._flush_layouts_to_X() + def on_remove_clicked(self, button): if not self._selection.count_selected_rows(): return @@ -541,6 +548,10 @@ def on_remove_clicked(self, button): # Re-emit the selection changed signal now that the backing store is updated # in order to update the first/last/only-based button sensitivities self._selection.emit("changed") + + if keyboard.can_configure_keyboard(): + self._flush_layouts_to_X() + return #nothing left, run AddLayout dialog to replace the current layout @@ -561,6 +572,9 @@ def on_remove_clicked(self, button): self._removeLayout(store, itr) self._selection.select_iter(itr2) + if keyboard.can_configure_keyboard(): + self._flush_layouts_to_X() + def on_up_clicked(self, button): if not self._selection.count_selected_rows(): return @@ -574,10 +588,6 @@ def on_up_clicked(self, button): if keyboard.can_configure_keyboard(): self._flush_layouts_to_X() - if not store.iter_previous(cur): - #layout is first in the list (set as default), activate it - self._xkl_wrapper.activate_default_layout() - self._selection.emit("changed") def on_down_clicked(self, button): @@ -586,9 +596,6 @@ def on_down_clicked(self, button): (store, cur) = self._selection.get_selected() - #if default layout (first in the list) changes we need to activate it - activate_default = not store.iter_previous(cur) - nxt = store.iter_next(cur) if not nxt: return @@ -597,9 +604,6 @@ def on_down_clicked(self, button): if keyboard.can_configure_keyboard(): self._flush_layouts_to_X() - if activate_default: - self._xkl_wrapper.activate_default_layout() - self._selection.emit("changed") def on_preview_clicked(self, button): @@ -698,10 +702,9 @@ def _add_data_layouts(self): valid_layouts = [] for layout in self._l12_module.XLayouts: - try: - self._addLayout(self._store, layout) + if self._addLayout(self._store, layout): valid_layouts += layout - except XklWrapperError: + else: log.error("Failed to add layout '%s'", layout) if not valid_layouts: @@ -716,3 +719,11 @@ def _flush_layouts_to_X(self): layouts_list.append(row[0]) self._xkl_wrapper.replace_layouts(layouts_list) + + def _on_compositor_selected_layout_changed(self, layout): + if not self._compositor_initial_layout: + self._compositor_initial_layout = layout + + @timed_action(busy_cursor=False) + def _on_compositor_layouts_changed(self, layouts): + self._xkl_wrapper.activate_default_layout() diff --git a/pyanaconda/ui/gui/xkl_wrapper.py b/pyanaconda/ui/gui/xkl_wrapper.py index c306840e1e1..974bc8e2f2f 100644 --- a/pyanaconda/ui/gui/xkl_wrapper.py +++ b/pyanaconda/ui/gui/xkl_wrapper.py @@ -16,62 +16,31 @@ # Red Hat, Inc. # -""" -This module include functions and classes for dealing with multiple layouts in -Anaconda. It wraps the libxklavier functionality to protect Anaconda from -dealing with its "nice" API that looks like a Lisp-influenced "good old C" and -also systemd-localed functionality. - -It provides a XklWrapper class with several methods that can be used for listing -and various modifications of keyboard layouts settings. - -""" - -import gi -gi.require_version("GdkX11", "3.0") -gi.require_version("Xkl", "1.0") - -from gi.repository import GdkX11, Xkl - import iso639 import threading import gettext from collections import namedtuple from xkbregistry import rxkb -from pyanaconda.core.configuration.anaconda import conf -from pyanaconda.core.constants import DEFAULT_KEYBOARD -from pyanaconda.core.string import upcase_first_letter -from pyanaconda.keyboard import join_layout_variant, parse_layout_variant, \ - KeyboardConfigError, InvalidLayoutVariantSpec, normalize_layout_variant from pyanaconda.core.async_utils import async_action_wait +from pyanaconda.core.string import upcase_first_letter +from pyanaconda.keyboard import normalize_layout_variant +from pyanaconda.modules.common.constants.services import LOCALIZATION from pyanaconda import localization - -from pyanaconda.anaconda_loggers import get_module_logger -log = get_module_logger(__name__) - Xkb_ = lambda x: gettext.translation("xkeyboard-config", fallback=True).gettext(x) iso_ = lambda x: gettext.translation("iso_639", fallback=True).gettext(x) # namedtuple for information about a keyboard layout (its language and description) LayoutInfo = namedtuple("LayoutInfo", ["langs", "desc"]) - -class XklWrapperError(KeyboardConfigError): - """Exception class for reporting libxklavier-related problems""" - - pass - - class XklWrapper(object): """ - Class wrapping the libxklavier functionality - - Use this class as a singleton class because it provides read-only data - and initialization (that takes quite a lot of time) reads always the - same data. It doesn't have sense to make multiple instances + Class that used to wrap libxklavier functionality. + libxklavier is deprecated and X11-only. On RHEL, the GNOME Kiosk API is used + instead. This class is kept to keep make the code migration as simple as + possible. """ _instance = None @@ -86,39 +55,8 @@ def get_instance(): return XklWrapper._instance def __init__(self): - #initialize Xkl-related stuff - display = GdkX11.x11_get_default_xdisplay() - self._engine = Xkl.Engine.get_instance(display) - - self._rec = Xkl.ConfigRec() - if not self._rec.get_from_server(self._engine): - raise XklWrapperError("Failed to get configuration from server") - - #X is probably initialized to the 'us' layout without any variant and - #since we want to add layouts with variants we need the layouts and - #variants lists to have the same length. Add "" padding to variants. - #See docstring of the add_layout method for details. - diff = len(self._rec.layouts) - len(self._rec.variants) - if diff > 0 and conf.system.can_activate_layouts: - self._rec.set_variants(self._rec.variants + (diff * [""])) - if not self._rec.activate(self._engine): - # failed to activate layouts given e.g. by a kickstart (may be - # invalid) - lay_var_str = ",".join(map(join_layout_variant, - self._rec.layouts, - self._rec.variants)) - log.error("Failed to activate layouts: '%s', " - "falling back to default %s", lay_var_str, DEFAULT_KEYBOARD) - self._rec.set_layouts([DEFAULT_KEYBOARD]) - self._rec.set_variants([""]) - - if not self._rec.activate(self._engine): - # failed to activate even the default layout, something is - # really wrong - raise XklWrapperError("Failed to initialize layouts") - - self.configreg = Xkl.ConfigRegistry.get_instance(self._engine) - self.configreg.load(False) + self._keyboard_manager = LOCALIZATION.get_proxy() + self._switching_options = [] self._rxkb = rxkb.Context() @@ -153,35 +91,27 @@ def _build_switch_opt_infos(self): for option in group.options.values(): self._switch_opt_infos[option.name] = option.description - def get_current_layout(self): - """ - Get current activated X layout and variant + @property + def compositor_selected_layout_changed(self): + """Signal emitted when the selected keyboard layout changes.""" + return self._keyboard_manager.CompositorSelectedLayoutChanged - :return: current activated X layout and variant (e.g. "cz (qwerty)") + @property + def compositor_layouts_changed(self): + """Signal emitted when available layouts change.""" + return self._keyboard_manager.CompositorLayoutsChanged + @async_action_wait + def get_current_layout(self): """ - # ported from the widgets/src/LayoutIndicator.c code + Get current activated layout and variant - self._engine.start_listen(Xkl.EngineListenModes.TRACK_KEYBOARD_STATE) - state = self._engine.get_current_state() - cur_group = state.group - num_groups = self._engine.get_num_groups() + :return: current activated layout and variant (e.g. "cz (qwerty)") - # BUG?: if the last layout in the list is activated and removed, - # state.group may be equal to n_groups - if cur_group >= num_groups: - cur_group = num_groups - 1 - - layout = self._rec.layouts[cur_group] # pylint: disable=unsubscriptable-object - try: - variant = self._rec.variants[cur_group] # pylint: disable=unsubscriptable-object - except IndexError: - # X server may have forgotten to add the "" variant for its default layout - variant = "" - - self._engine.stop_listen(Xkl.EngineListenModes.TRACK_KEYBOARD_STATE) + :raise KeyboardConfigError: if layouts with invalid backend type is found + """ - return join_layout_variant(layout, variant) + return self._keyboard_manager.GetCompositorSelectedLayout() def get_available_layouts(self): """A list of layouts""" @@ -262,107 +192,31 @@ def activate_default_layout(self): Activates default layout (the first one in the list of configured layouts). + :raise KeyboardConfigError: if layouts with invalid backend type is found """ - self._engine.lock_group(0) + layouts = self._keyboard_manager.GetCompositorLayouts() + if not layouts: + return + + self._keyboard_manager.SetCompositorSelectedLayout(layouts[0]) def is_valid_layout(self, layout): """Return if given layout is valid layout or not""" return layout in self._layout_infos - @async_action_wait - def add_layout(self, layout): - """ - Method that tries to add a given layout to the current X configuration. - - The X layouts configuration is handled by two lists. A list of layouts - and a list of variants. Index-matching items in these lists (as if they - were zipped) are used for the construction of real layouts (e.g. - 'cz (qwerty)'). - - :param layout: either 'layout' or 'layout (variant)' - :raise XklWrapperError: if the given layout is invalid or cannot be added - - """ - - try: - #we can get 'layout' or 'layout (variant)' - (layout, variant) = parse_layout_variant(layout) - except InvalidLayoutVariantSpec as ilverr: - raise XklWrapperError("Failed to add layout: %s" % ilverr) from ilverr - - #do not add the same layout-variant combinanion multiple times - if (layout, variant) in list(zip(self._rec.layouts, self._rec.variants)): - return - - self._rec.set_layouts(self._rec.layouts + [layout]) - self._rec.set_variants(self._rec.variants + [variant]) - - if not self._rec.activate(self._engine): - raise XklWrapperError("Failed to add layout '%s (%s)'" % (layout, - variant)) - - @async_action_wait - def remove_layout(self, layout): - """ - Method that tries to remove a given layout from the current X - configuration. - - See also the documentation for the add_layout method. - - :param layout: either 'layout' or 'layout (variant)' - :raise XklWrapperError: if the given layout cannot be removed - - """ - - #we can get 'layout' or 'layout (variant)' - (layout, variant) = parse_layout_variant(layout) - - layouts_variants = list(zip(self._rec.layouts, self._rec.variants)) - - if (layout, variant) not in layouts_variants: - msg = "'%s (%s)' not in the list of added layouts" % (layout, - variant) - raise XklWrapperError(msg) - - idx = layouts_variants.index((layout, variant)) - new_layouts = self._rec.layouts[:idx] + self._rec.layouts[(idx + 1):] # pylint: disable=unsubscriptable-object - new_variants = self._rec.variants[:idx] + self._rec.variants[(idx + 1):] # pylint: disable=unsubscriptable-object - - self._rec.set_layouts(new_layouts) - self._rec.set_variants(new_variants) - - if not self._rec.activate(self._engine): - raise XklWrapperError("Failed to remove layout '%s (%s)'" % (layout, - variant)) - @async_action_wait def replace_layouts(self, layouts_list): """ - Method that replaces the layouts defined in the current X configuration + Method that replaces the layouts defined in the current configuration with the new ones given. :param layouts_list: list of layouts defined as either 'layout' or 'layout (variant)' - :raise XklWrapperError: if layouts cannot be replaced with the new ones - """ - new_layouts = list() - new_variants = list() - - for layout_variant in layouts_list: - (layout, variant) = parse_layout_variant(layout_variant) - new_layouts.append(layout) - new_variants.append(variant) - - self._rec.set_layouts(new_layouts) - self._rec.set_variants(new_variants) - - if not self._rec.activate(self._engine): - msg = "Failed to replace layouts with: %s" % ",".join(layouts_list) - raise XklWrapperError(msg) + self._keyboard_manager.SetCompositorLayouts(layouts_list, self._switching_options) @async_action_wait def set_switching_options(self, options): @@ -372,17 +226,17 @@ def set_switching_options(self, options): :param options: layout switching options to be set :type options: list or generator - :raise XklWrapperError: if the old options cannot be replaced with the - new ones + :raise KeyboardConfigError: if layouts with invalid backend type is found """ #preserve old "non-switching options" - new_options = [opt for opt in self._rec.options if "grp:" not in opt] # pylint: disable=not-an-iterable + new_options = [opt for opt in self._switching_options if "grp:" not in opt] new_options += options + self._switching_options = new_options - self._rec.set_options(new_options) + layouts = self._keyboard_manager.GetCompositorLayouts() + if not layouts: + return - if not self._rec.activate(self._engine): - msg = "Failed to set switching options to: %s" % ",".join(options) - raise XklWrapperError(msg) + self._keyboard_manager.SetCompositorLayouts(layouts, self._switching_options) From fad1a6b9aca528ca4d4214dc8b5fab8dafe9a338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Exp=C3=B3sito?= Date: Thu, 8 Feb 2024 11:31:12 +0100 Subject: [PATCH 03/31] Setup gdbus-codegen Invoke gdbus-codegen as part of the make build and makeupdates script to generate C code from org.fedoraproject.Anaconda.Modules.Localization.xml. Resolves: RHEL-38399 (cherry picked from commit 68cde1a59eed3f6a6439de4a12c61b97782128d9) --- .gitignore | 1 + anaconda.spec.in | 2 + scripts/makeupdates | 10 ++ widgets/src/Makefile.am | 25 ++- ...aproject.Anaconda.Modules.Localization.xml | 168 ++++++++++++++++++ 5 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 widgets/src/dbus/org.fedoraproject.Anaconda.Modules.Localization.xml diff --git a/.gitignore b/.gitignore index c47a093aaee..b4ea297cef6 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ widgets/doc/xml widgets/gtk-doc.make widgets/src/gettext.h widgets/src/resources.* +widgets/src/an-localization.* pyanaconda/version.py .doctrees _sources diff --git a/anaconda.spec.in b/anaconda.spec.in index efe1d35bf83..a3b541b657e 100644 --- a/anaconda.spec.in +++ b/anaconda.spec.in @@ -303,6 +303,8 @@ Requires: system-logos # Needed to compile the gsettings files BuildRequires: gsettings-desktop-schemas +# Needed for gdbus-codegen +BuildRequires: glib2-devel %description gui This package contains graphical user interface for the Anaconda installer. diff --git a/scripts/makeupdates b/scripts/makeupdates index eac3a3f1719..2b1dddf20e4 100755 --- a/scripts/makeupdates +++ b/scripts/makeupdates @@ -325,6 +325,15 @@ def check_autotools(srcdir, builddir): os.system(os.path.join(srcdir, 'configure') + ' --prefix=`rpm --eval %_prefix`') os.chdir(srcdir) +def generate_dbus_code(srcdir): + os.system('gdbus-codegen ' + '--interface-prefix org.fedoraproject.Anaconda.Modules. ' + '--c-namespace An ' + '--generate-c-code an-localization ' + '--output-directory %s/widgets/src ' + '%s/widgets/src/dbus/org.fedoraproject.Anaconda.Modules.Localization.xml' + % (srcdir, srcdir)) + def copy_updated_widgets(updates, srcdir, builddir): os.chdir(srcdir) @@ -481,6 +490,7 @@ def main(): copy_updated_files(args.tag, updates, cwd, builddir) if args.compile: + generate_dbus_code(cwd) if widgets_changed(args.tag): copy_updated_widgets(updates, cwd, builddir) diff --git a/widgets/src/Makefile.am b/widgets/src/Makefile.am index d5af7f74483..e87dc5a4cb1 100644 --- a/widgets/src/Makefile.am +++ b/widgets/src/Makefile.am @@ -47,9 +47,13 @@ NONGISOURCES = NONGIHDRS = -SOURCES = $(GISOURCES) $(NONGISOURCES) +DBUSSOURCES = an-localization.c -HDRS = $(GIHDRS) $(NONGIHDRS) +DBUSHEADERS = an-localization.h + +SOURCES = $(GISOURCES) $(NONGISOURCES) $(DBUSSOURCES) + +HDRS = $(GIHDRS) $(NONGIHDRS) $(DBUSHEADERS) WIDGETSDATA = '"$(datadir)/anaconda"' @@ -105,7 +109,12 @@ resources.c: $(RESOURCE_XML) $(RESOURCE_DEPS) Makefile nodist_libAnacondaWidgets_la_SOURCES = resources.c resources.h -CLEANFILES = resources.c resources.h $(RESOURCE_XML) +CLEANFILES = \ + resources.c \ + resources.h \ + an-localization.c \ + an-localization.h \ + $(RESOURCE_XML) MAINTAINERCLEANFILES = gettext.h @@ -125,3 +134,13 @@ typelib_DATA = $(INTROSPECTION_GIRS:.gir=.typelib) CLEANFILES += AnacondaWidgets-3.4.gir $(typelib_DATA) MAINTAINERCLEANFILES += Makefile.in endif + +# Source files generated by gdbus-codegen +an-localization.c an-localization.h &: + gdbus-codegen \ + --interface-prefix org.fedoraproject.Anaconda.Modules. \ + --c-namespace An \ + --generate-c-code an-localization \ + --output-directory $(srcdir) \ + $(srcdir)/dbus/org.fedoraproject.Anaconda.Modules.Localization.xml +LayoutIndicator.c: an-localization.c an-localization.h diff --git a/widgets/src/dbus/org.fedoraproject.Anaconda.Modules.Localization.xml b/widgets/src/dbus/org.fedoraproject.Anaconda.Modules.Localization.xml new file mode 100644 index 00000000000..cef29035fc8 --- /dev/null +++ b/widgets/src/dbus/org.fedoraproject.Anaconda.Modules.Localization.xml @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 5deaf8f673fe3a7191919a711487df93de41e612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Exp=C3=B3sito?= Date: Thu, 8 Feb 2024 15:29:11 +0100 Subject: [PATCH 04/31] Use GNOME Kiosk's API in LayoutIndicator This C widget is the last place were libxklavier is used. Use GNOME Kiosk's API via the localization service instead. Resolves: RHEL-38399 (cherry picked from commit 9a611fdcc1ad5f663d6efccf68e7ebc1ca794703) --- widgets/src/LayoutIndicator.c | 284 +++++++++++++++++----------------- widgets/src/LayoutIndicator.h | 7 - 2 files changed, 139 insertions(+), 152 deletions(-) diff --git a/widgets/src/LayoutIndicator.c b/widgets/src/LayoutIndicator.c index 9fcd983e60a..49e4212fa8f 100644 --- a/widgets/src/LayoutIndicator.c +++ b/widgets/src/LayoutIndicator.c @@ -20,13 +20,14 @@ #include "config.h" #include +#include #include #include #include #include -#include #include "LayoutIndicator.h" +#include "an-localization.h" #include "intl.h" #include "widgets-common.h" @@ -34,6 +35,8 @@ #define SINGLE_LAYOUT_TIP _("Current layout: '%s'. Add more layouts to enable switching.") #define DEFAULT_LAYOUT "us" #define DEFAULT_LABEL_MAX_CHAR_WIDTH 8 +#define ANACONDA_BUS_ADDR_FILE "/run/anaconda/bus.address" +#define DBUS_ANACONDA_SESSION_ADDRESS "DBUS_ANACONDA_SESSION_BUS_ADDRESS" /** * SECTION: AnacondaLayoutIndicator @@ -77,11 +80,7 @@ struct _AnacondaLayoutIndicatorPrivate { GtkWidget *icon; GtkLabel *layout_label; GdkCursor *cursor; - XklConfigRec *config_rec; - gulong state_changed_handler_id; - gboolean state_changed_handler_id_set; - gulong config_changed_handler_id; - gboolean config_changed_handler_id_set; + AnLocalization *localization_proxy; }; G_DEFINE_TYPE(AnacondaLayoutIndicator, anaconda_layout_indicator, GTK_TYPE_EVENT_BOX) @@ -97,13 +96,6 @@ static void anaconda_layout_indicator_refresh_ui_elements(AnacondaLayoutIndicato static void anaconda_layout_indicator_refresh_layout(AnacondaLayoutIndicator *indicator); static void anaconda_layout_indicator_refresh_tooltip(AnacondaLayoutIndicator *indicator); -/* helper functions */ -static gchar* get_current_layout(XklEngine *engine, XklConfigRec *conf_rec); -static void x_state_changed(XklEngine *engine, XklEngineStateChange type, - gint arg2, gboolean arg3, gpointer indicator); -static void x_config_changed(XklEngine *engine, gpointer indicator); -static GdkFilterReturn handle_xevent(GdkXEvent *xev, GdkEvent *event, gpointer engine); - static void anaconda_layout_indicator_class_init(AnacondaLayoutIndicatorClass *klass) { GObjectClass *object_class = G_OBJECT_CLASS(klass); GtkWidgetClass *widget_class = GTK_WIDGET_CLASS(klass); @@ -160,29 +152,132 @@ GtkWidget *anaconda_layout_indicator_new() { return g_object_new(ANACONDA_TYPE_LAYOUT_INDICATOR, NULL); } +static void anaconda_localization_on_layouts_changed(AnLocalization *proxy, + const gchar *const *layouts, + AnacondaLayoutIndicator *self) { + anaconda_layout_indicator_refresh_layout(self); +} + +static void anaconda_localization_on_selected_layout_changed(AnLocalization *proxy, + const gchar *layout, + AnacondaLayoutIndicator *self) { + anaconda_layout_indicator_refresh_layout(self); +} + +static gchar *anaconda_localization_get_bus_addr(void) { + gchar *bus_addr; + gboolean res; + + bus_addr = (gchar *)g_getenv(DBUS_ANACONDA_SESSION_ADDRESS); + if (bus_addr) { + return g_strdup(bus_addr); + } + + res = g_file_get_contents(ANACONDA_BUS_ADDR_FILE, + &bus_addr, + NULL, + NULL); + if (res) { + return bus_addr; + } + + return NULL; +} + +static void anaconda_localization_connect(AnacondaLayoutIndicator *self) { + gchar *bus_addr; + GDBusConnection *bus; + AnLocalization *proxy; + g_autoptr(GError) error = NULL; + + bus_addr = anaconda_localization_get_bus_addr(); + if (!bus_addr) { + g_warning("Error getting Anaconda bus address"); + return; + } + + bus = g_dbus_connection_new_for_address_sync(bus_addr, + G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION, + NULL, + NULL, + &error); + g_free(bus_addr); + if (!bus) { + g_warning("Error getting Anaconda bus: %s", error->message); + return; + } + + proxy = an_localization_proxy_new_sync(bus, + G_DBUS_PROXY_FLAGS_NONE, + "org.fedoraproject.Anaconda.Modules.Localization", + "/org/fedoraproject/Anaconda/Modules/Localization", + NULL, + &error); + if (!proxy) { + g_warning("Failed to connect to Anaconda's localization module: %s", error->message); + return; + } + + g_signal_connect_object(G_OBJECT(proxy), + "compositor-layouts-changed", + G_CALLBACK(anaconda_localization_on_layouts_changed), + self, + G_CONNECT_DEFAULT); + g_signal_connect_object(G_OBJECT(proxy), + "compositor-selected-layout-changed", + G_CALLBACK(anaconda_localization_on_selected_layout_changed), + self, + G_CONNECT_DEFAULT); + + self->priv->localization_proxy = proxy; +} + +static gchar *anaconda_localization_get_current_layout(AnacondaLayoutIndicator *self) { + gboolean result; + gchar *layout = NULL; + g_autoptr(GError) error = NULL; + + result = an_localization_call_get_compositor_selected_layout_sync(self->priv->localization_proxy, + &layout, + NULL, + &error); + if (!result || g_str_equal(layout, "")) { + if (layout) + g_free(layout); + return g_strdup(DEFAULT_LAYOUT); + } + + return layout; +} + +static int anaconda_localization_get_num_layouts(AnacondaLayoutIndicator *self) { + gboolean result; + gchar **layouts = NULL; + g_autoptr(GError) error = NULL; + int n_groups; + + result = an_localization_call_get_compositor_layouts_sync(self->priv->localization_proxy, + &layouts, + NULL, + &error); + if (!result) { + g_warning("Error getting compositor layouts: %s", error->message); + return -1; + } + + n_groups = g_strv_length(layouts); + g_strfreev(layouts); + return n_groups; +} + +static void anaconda_localization_select_next_layout(AnacondaLayoutIndicator *self) { + an_localization_call_select_next_compositor_layout_sync(self->priv->localization_proxy, + NULL, + NULL); +} + static void anaconda_layout_indicator_init(AnacondaLayoutIndicator *self) { AtkObject *atk; - GdkDisplay *display; - AnacondaLayoutIndicatorClass *klass = ANACONDA_LAYOUT_INDICATOR_GET_CLASS(self); - - if (!klass->engine) { - /* This code cannot go to class_init because that way it would be called - when GObject type system is initialized and Gdk won't give us the - display. Thus the first instance being created has to populate this - class-wide stuff */ - - /* initialize XklEngine instance that will be used by all LayoutIndicator instances */ - display = gdk_display_get_default(); - klass->engine = xkl_engine_get_instance(GDK_DISPLAY_XDISPLAY(display)); - - /* make XklEngine listening */ - xkl_engine_start_listen(klass->engine, XKLL_TRACK_KEYBOARD_STATE); - - /* hook up X events with XklEngine - * (passing NULL as the first argument means we want X events from all windows) - */ - gdk_window_add_filter(NULL, (GdkFilterFunc) handle_xevent, klass->engine); - } self->priv = G_TYPE_INSTANCE_GET_PRIVATE(self, ANACONDA_TYPE_LAYOUT_INDICATOR, @@ -208,22 +303,9 @@ static void anaconda_layout_indicator_init(AnacondaLayoutIndicator *self) { G_CALLBACK(anaconda_layout_indicator_realize), NULL); - /* initialize XklConfigRec instance providing data */ - self->priv->config_rec = xkl_config_rec_new(); - xkl_config_rec_get_from_server(self->priv->config_rec, klass->engine); - - /* hook up handler for "X-state-changed" and "X-config-changed" signals */ - self->priv->state_changed_handler_id = g_signal_connect(klass->engine, "X-state-changed", - G_CALLBACK(x_state_changed), - g_object_ref(self)); - self->priv->state_changed_handler_id_set = TRUE; - self->priv->config_changed_handler_id = g_signal_connect(klass->engine, "X-config-changed", - G_CALLBACK(x_config_changed), - g_object_ref(self)); - self->priv->config_changed_handler_id_set = TRUE; - /* init layout attribute with the current layout */ - self->priv->layout = get_current_layout(klass->engine, self->priv->config_rec); + anaconda_localization_connect(self); + self->priv->layout = anaconda_localization_get_current_layout(self); /* create layout label and set desired properties */ self->priv->layout_label = GTK_LABEL(gtk_label_new(NULL)); @@ -266,20 +348,6 @@ static void anaconda_layout_indicator_init(AnacondaLayoutIndicator *self) { static void anaconda_layout_indicator_dispose(GObject *object) { AnacondaLayoutIndicator *self = ANACONDA_LAYOUT_INDICATOR(object); - AnacondaLayoutIndicatorClass *klass = ANACONDA_LAYOUT_INDICATOR_GET_CLASS(self); - - /* disconnect signals (XklEngine will outlive us) */ - if (self->priv->state_changed_handler_id_set) - { - g_signal_handler_disconnect(klass->engine, self->priv->state_changed_handler_id); - self->priv->state_changed_handler_id_set = FALSE; - } - - if (self->priv->config_changed_handler_id_set) - { - g_signal_handler_disconnect(klass->engine, self->priv->config_changed_handler_id); - self->priv->config_changed_handler_id_set = FALSE; - } /* unref all objects we reference (may be called multiple times) */ if (self->priv->layout_label) { @@ -290,14 +358,13 @@ static void anaconda_layout_indicator_dispose(GObject *object) { g_object_unref(self->priv->cursor); self->priv->cursor = NULL; } - if (self->priv->config_rec) { - g_object_unref(self->priv->config_rec); - self->priv->config_rec = NULL; - } if (self->priv->layout) { g_free(self->priv->layout); self->priv->layout = NULL; } + if (self->priv->localization_proxy) { + g_clear_object(&self->priv->localization_proxy); + } G_OBJECT_CLASS(anaconda_layout_indicator_parent_class)->dispose(object); } @@ -338,19 +405,13 @@ static void anaconda_layout_indicator_set_property(GObject *object, guint prop_i static void anaconda_layout_indicator_clicked(GtkWidget *widget, GdkEvent *event, gpointer data) { AnacondaLayoutIndicator *self = ANACONDA_LAYOUT_INDICATOR(widget); - AnacondaLayoutIndicatorClass *klass = ANACONDA_LAYOUT_INDICATOR_GET_CLASS(self); if (event->type != GDK_BUTTON_RELEASE) return; - XklState *state = xkl_engine_get_current_state(klass->engine); - guint n_groups = xkl_engine_get_num_groups(klass->engine); - - /* cycle over groups */ - guint next_group = (state->group + 1) % n_groups; - - /* activate next group */ - xkl_engine_lock_group(klass->engine, next_group); + int n_groups = anaconda_localization_get_num_layouts(self); + if (n_groups > 1) + anaconda_localization_select_next_layout(self); } static void anaconda_layout_indicator_refresh_ui_elements(AnacondaLayoutIndicator *self) { @@ -361,10 +422,9 @@ static void anaconda_layout_indicator_refresh_ui_elements(AnacondaLayoutIndicato static void anaconda_layout_indicator_refresh_layout(AnacondaLayoutIndicator *self) { AtkObject *atk; - AnacondaLayoutIndicatorClass *klass = ANACONDA_LAYOUT_INDICATOR_GET_CLASS(self); g_free(self->priv->layout); - self->priv->layout = get_current_layout(klass->engine, self->priv->config_rec); + self->priv->layout = anaconda_localization_get_current_layout(self); atk = gtk_widget_get_accessible(GTK_WIDGET(self)); atk_object_set_description(atk, self->priv->layout); @@ -373,8 +433,7 @@ static void anaconda_layout_indicator_refresh_layout(AnacondaLayoutIndicator *se } static void anaconda_layout_indicator_refresh_tooltip(AnacondaLayoutIndicator *self) { - AnacondaLayoutIndicatorClass *klass = ANACONDA_LAYOUT_INDICATOR_GET_CLASS(self); - guint n_groups = xkl_engine_get_num_groups(klass->engine); + int n_groups = anaconda_localization_get_num_layouts(self); gchar *tooltip; if (n_groups > 1) @@ -386,71 +445,6 @@ static void anaconda_layout_indicator_refresh_tooltip(AnacondaLayoutIndicator *s g_free(tooltip); } -/** - * get_current_layout: - * - * Returns: newly allocated string with the currently activated layout as - * 'layout (variant)' - */ -static gchar* get_current_layout(XklEngine *engine, XklConfigRec *conf_rec) { - /* engine has to be listening with XKLL_TRACK_KEYBOARD_STATE mask */ - gchar *layout = NULL; - gchar *variant = NULL; - gint32 cur_group; - - /* returns statically allocated buffer, shouldn't be freed */ - XklState *state = xkl_engine_get_current_state(engine); - cur_group = state->group; - - guint n_groups = xkl_engine_get_num_groups(engine); - - /* BUG?: if the last layout in the list is activated and removed, - state->group may be equal to n_groups that would result in - layout being NULL - */ - if (cur_group >= n_groups) - cur_group = n_groups - 1; - - layout = conf_rec->layouts[cur_group]; - - /* variant defined for the current layout */ - variant = conf_rec->variants[cur_group]; - - /* variant may be NULL or "" if not defined */ - if (variant && g_strcmp0("", variant)) - return g_strdup_printf("%s (%s)", layout, variant); - else - return g_strdup(layout); -} - -static GdkFilterReturn handle_xevent(GdkXEvent *xev, GdkEvent *event, gpointer data) { - XklEngine *engine = XKL_ENGINE(data); - XEvent *xevent = (XEvent *) xev; - - xkl_engine_filter_events(engine, xevent); - - return GDK_FILTER_CONTINUE; -} - -static void x_state_changed(XklEngine *engine, XklEngineStateChange type, - gint arg2, gboolean arg3, gpointer data) { - g_return_if_fail(data); - AnacondaLayoutIndicator *indicator = ANACONDA_LAYOUT_INDICATOR(data); - - anaconda_layout_indicator_refresh_layout(indicator); -} - -static void x_config_changed(XklEngine *engine, gpointer data) { - g_return_if_fail(data); - AnacondaLayoutIndicator *indicator = ANACONDA_LAYOUT_INDICATOR(data); - AnacondaLayoutIndicatorClass *klass = ANACONDA_LAYOUT_INDICATOR_GET_CLASS(indicator); - - /* load current configuration from the X server */ - xkl_config_rec_get_from_server(indicator->priv->config_rec, klass->engine); - - anaconda_layout_indicator_refresh_layout(indicator); -} - /** * anaconda_layout_indicator_get_current_layout: * @indicator: a #AnacondaLayoutIndicator diff --git a/widgets/src/LayoutIndicator.h b/widgets/src/LayoutIndicator.h index 443d01b2b2a..99b7dbe1573 100644 --- a/widgets/src/LayoutIndicator.h +++ b/widgets/src/LayoutIndicator.h @@ -19,7 +19,6 @@ #define _LAYOUT_INDICATOR_H #include -#include G_BEGIN_DECLS @@ -52,15 +51,9 @@ struct _AnacondaLayoutIndicator { * the widget class structure in order for the class mechanism * to work correctly. This allows an AnacondaLayoutIndicatorClass * pointer to be cast to a #GtkEventBox pointer. - * @engine: A singleton XklEngine instance that is used by all instances of - * LayoutIndicator. */ struct _AnacondaLayoutIndicatorClass { GtkEventBoxClass parent_class; - - /* this has to be a class attribute, because XklEngine is a singleton that - should be used by all instances */ - XklEngine *engine; }; GType anaconda_layout_indicator_get_type (void); From c69eb69ea8f40b3f471ae67ab0598eb43158b9de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Exp=C3=B3sito?= Date: Thu, 8 Feb 2024 15:32:32 +0100 Subject: [PATCH 05/31] Drop libxklavier Resolves: RHEL-38399 Resolves: RHBZ#1955025 (cherry picked from commit ce6cc95063302ae4c8bab2eee1d16644e02b92ab) --- anaconda.spec.in | 3 --- widgets/configure.ac | 1 - widgets/src/Makefile.am | 6 +++--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/anaconda.spec.in b/anaconda.spec.in index a3b541b657e..8f0344c917b 100644 --- a/anaconda.spec.in +++ b/anaconda.spec.in @@ -36,7 +36,6 @@ Source0: https://github.com/rhinstaller/%{name}/releases/download/%{name}-%{vers %define libarchivever 3.0.4 %define libblockdevver 2.1 %define libreportanacondaver 2.0.21-1 -%define libxklavierver 5.4 %define mehver 0.23-1 %define nmver 1.0 %define pykickstartver 3.58-1 @@ -58,7 +57,6 @@ BuildRequires: gobject-introspection-devel %if %{with glade} BuildRequires: glade-devel %endif -BuildRequires: libxklavier-devel >= %{libxklavierver} BuildRequires: make BuildRequires: pango-devel BuildRequires: python3-devel @@ -291,7 +289,6 @@ Requires: python3-xkbregistry Requires: adwaita-icon-theme Requires: tecla Requires: tigervnc-server-minimal -Requires: libxklavier >= %{libxklavierver} Requires: nm-connection-editor %ifnarch s390 s390x Requires: NetworkManager-wifi diff --git a/widgets/configure.ac b/widgets/configure.ac index 4d3ecaf88c4..3f8f0bc1123 100644 --- a/widgets/configure.ac +++ b/widgets/configure.ac @@ -72,7 +72,6 @@ AS_IF([test "x$enable_glade" != "xno"], [AC_SUBST(GLADE_SUBDIR, "")]) ANACONDA_PKG_CHECK_MODULES([GTK], [gtk+-x11-3.0 >= 3.11.3]) -ANACONDA_PKG_CHECK_MODULES([LIBXKLAVIER], [libxklavier >= 5.2.1]) ANACONDA_PKG_CHECK_EXISTS([gobject-introspection-1.0 >= 1.30]) # Use AM_PATH_GLIB_2_0 to define some extra glib-related variables diff --git a/widgets/src/Makefile.am b/widgets/src/Makefile.am index e87dc5a4cb1..34fd0d4a5a2 100644 --- a/widgets/src/Makefile.am +++ b/widgets/src/Makefile.am @@ -60,9 +60,9 @@ WIDGETSDATA = '"$(datadir)/anaconda"' noinst_HEADERS = gettext.h intl.h lib_LTLIBRARIES = libAnacondaWidgets.la -libAnacondaWidgets_la_CFLAGS = $(GTK_CFLAGS) $(LIBXKLAVIER_CFLAGS) -Wall -g\ +libAnacondaWidgets_la_CFLAGS = $(GTK_CFLAGS) -Wall -g\ -DWIDGETS_DATADIR=$(WIDGETSDATA) -libAnacondaWidgets_la_LIBADD = $(GTK_LIBS) $(LIBXKLAVIER_LIBS) +libAnacondaWidgets_la_LIBADD = $(GTK_LIBS) libAnacondaWidgets_la_LDFLAGS = $(LTLIBINTL) -version-info 4:0:0 libAnacondaWidgets_la_SOURCES = $(SOURCES) $(HDRS) @@ -124,7 +124,7 @@ AnacondaWidgets-3.4.gir: libAnacondaWidgets.la AnacondaWidgets_3_4_gir_FILES = $(GISOURCES) $(GIHDRS) AnacondaWidgets_3_4_gir_LIBS = libAnacondaWidgets.la AnacondaWidgets_3_4_gir_SCANNERFLAGS = --warn-all --identifier-prefix=Anaconda --symbol-prefix=anaconda -AnacondaWidgets_3_4_gir_INCLUDES = Gtk-3.0 Xkl-1.0 +AnacondaWidgets_3_4_gir_INCLUDES = Gtk-3.0 INTROSPECTION_GIRS = AnacondaWidgets-3.4.gir From c4029ba606a1ee08fc4de09cbb7d30f751329179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Exp=C3=B3sito?= Date: Wed, 21 Feb 2024 11:41:37 +0100 Subject: [PATCH 06/31] Drop the X.Org server dependency Start GNOME Kiosk as a Wayland compositor and run Anaconda as a native Wayland client. This commit is a follow up on the work done by Neal Gompa [1], Martin Kolman and Ray Strode [2]. Credit goes to them for the code I copied and pasted. [1] https://github.com/rhinstaller/anaconda/pull/5401 [2] https://github.com/rhinstaller/anaconda/pull/5309 Co-authored-by: Neal Gompa Co-authored-by: Martin Kolman Co-authored-by: Ray Strode Resolves: RHEL-38399 Resolves: https://fedoraproject.org/wiki/Changes/AnacondaWebUIforFedoraWorkstation (cherry picked from commit 8800331efab8459882596fe69706d7c52b3f1aa7) --- anaconda.py | 13 +- anaconda.spec.in | 4 +- configure.ac | 1 + data/Makefile.am | 2 +- data/pam/Makefile.am | 21 ++++ data/pam/anaconda | 8 ++ data/systemd/anaconda.service | 2 +- pyanaconda/core/constants.py | 3 + pyanaconda/core/util.py | 2 +- pyanaconda/display.py | 85 ++++++------- pyanaconda/flags.py | 1 + scripts/Makefile.am | 3 +- scripts/makeupdates | 3 +- scripts/run-in-new-session | 231 ++++++++++++++++++++++++++++++++++ 14 files changed, 323 insertions(+), 56 deletions(-) create mode 100644 data/pam/Makefile.am create mode 100644 data/pam/anaconda create mode 100755 scripts/run-in-new-session diff --git a/anaconda.py b/anaconda.py index e438f6aa8ed..d7990a0e444 100755 --- a/anaconda.py +++ b/anaconda.py @@ -150,6 +150,12 @@ def setup_environment(): if "LD_PRELOAD" in os.environ: del os.environ["LD_PRELOAD"] + # Go ahead and set $WAYLAND_DISPLAY whether we're going to use Wayland or not + if "WAYLAND_DISPLAY" in os.environ: + flags.preexisting_wayland = True + else: + os.environ["WAYLAND_DISPLAY"] = constants.WAYLAND_SOCKET_NAME # pylint: disable=possibly-used-before-assignment + # Go ahead and set $DISPLAY whether we're going to use X or not if "DISPLAY" in os.environ: flags.preexisting_x11 = True @@ -306,10 +312,11 @@ def setup_environment(): except pid.PidFileError as e: log.error("Unable to create %s, exiting", pidfile.filename) - # If we had a $DISPLAY at start and zenity is available, we may be - # running in a live environment and we can display an error dialog. + # If we had a Wayland/X11 display at start and zenity is available, we may + # be running in a live environment and we can display an error dialog. # Otherwise just print an error. - if flags.preexisting_x11 and os.access("/usr/bin/zenity", os.X_OK): + preexisting_graphics = flags.preexisting_wayland or flags.preexisting_x11 + if preexisting_graphics and os.access("/usr/bin/zenity", os.X_OK): # The module-level _() calls are ok here because the language may # be set from the live environment in this case, and anaconda's # language setup hasn't happened yet. diff --git a/anaconda.spec.in b/anaconda.spec.in index 8f0344c917b..a8fbf0899ec 100644 --- a/anaconda.spec.in +++ b/anaconda.spec.in @@ -254,8 +254,6 @@ Requires: zram-generator # needed for proper driver disk support - if RPMs must be installed, a repo is needed Requires: createrepo_c # Display stuff moved from lorax templates -Requires: xorg-x11-drivers -Requires: xorg-x11-server-Xorg Requires: xrandr Requires: xrdb Requires: dbus-x11 @@ -264,6 +262,7 @@ Requires: nm-connection-editor Requires: librsvg2 Requires: gnome-kiosk Requires: brltty +Requires: python3-pam # dependencies for rpm-ostree payload module Requires: rpm-ostree >= %{rpmostreever} Requires: ostree @@ -408,6 +407,7 @@ rm -rf \ %{_sbindir}/anaconda %{_sbindir}/handle-sshpw %{_datadir}/anaconda +%{_sysconfdir}/pam.d/anaconda %{_prefix}/libexec/anaconda %exclude %{_datadir}/anaconda/gnome %exclude %{_datadir}/anaconda/pixmaps diff --git a/configure.ac b/configure.ac index f0deabe1529..4fe100c0dc5 100644 --- a/configure.ac +++ b/configure.ac @@ -118,6 +118,7 @@ AC_CONFIG_FILES([Makefile data/systemd/Makefile data/dbus/Makefile data/gtk-4.0/Makefile + data/pam/Makefile data/window-manager/Makefile data/window-manager/config/Makefile po/Makefile diff --git a/data/Makefile.am b/data/Makefile.am index d08f3005a0e..9c5cff7766b 100644 --- a/data/Makefile.am +++ b/data/Makefile.am @@ -15,7 +15,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . -SUBDIRS = command-stubs gtk-4.0 liveinst systemd pixmaps window-manager dbus conf.d profile.d +SUBDIRS = command-stubs gtk-4.0 liveinst systemd pam pixmaps window-manager dbus conf.d profile.d CLEANFILES = *~ diff --git a/data/pam/Makefile.am b/data/pam/Makefile.am new file mode 100644 index 00000000000..97e6657be15 --- /dev/null +++ b/data/pam/Makefile.am @@ -0,0 +1,21 @@ +# Copyright (C) 2024 Neal Gompa. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published +# by the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +CLEANFILES = *~ + +pamdir = $(sysconfdir)/pam.d +dist_pam_DATA = anaconda + +MAINTAINERCLEANFILES = Makefile.in diff --git a/data/pam/anaconda b/data/pam/anaconda new file mode 100644 index 00000000000..af8758d3b79 --- /dev/null +++ b/data/pam/anaconda @@ -0,0 +1,8 @@ +#%PAM-1.0 +auth sufficient pam_permit.so +account sufficient pam_permit.so +password sufficient pam_permit.so +session required pam_loginuid.so +-session optional pam_keyinit.so revoke +-session optional pam_limits.so +session required pam_systemd.so \ No newline at end of file diff --git a/data/systemd/anaconda.service b/data/systemd/anaconda.service index a80c6bb7075..0a3580b89ad 100644 --- a/data/systemd/anaconda.service +++ b/data/systemd/anaconda.service @@ -5,6 +5,6 @@ Wants=anaconda-noshell.service [Service] Type=forking -Environment=HOME=/root MALLOC_CHECK_=2 MALLOC_PERTURB_=204 PATH=/usr/bin:/bin:/sbin:/usr/sbin:/mnt/sysimage/bin:/mnt/sysimage/usr/bin:/mnt/sysimage/usr/sbin:/mnt/sysimage/sbin LANG=en_US.UTF-8 GDK_BACKEND=x11 XDG_RUNTIME_DIR=/tmp GIO_USE_VFS=local +Environment=HOME=/root MALLOC_CHECK_=2 MALLOC_PERTURB_=204 PATH=/usr/bin:/bin:/sbin:/usr/sbin:/mnt/sysimage/bin:/mnt/sysimage/usr/bin:/mnt/sysimage/usr/sbin:/mnt/sysimage/sbin LANG=en_US.UTF-8 GDK_BACKEND=wayland XDG_RUNTIME_DIR=/run/user/0 GIO_USE_VFS=local WorkingDirectory=/root ExecStart=/usr/bin/tmux -u -f /usr/share/anaconda/tmux.conf start diff --git a/pyanaconda/core/constants.py b/pyanaconda/core/constants.py index 06ff8cb0416..2f7dd28236a 100644 --- a/pyanaconda/core/constants.py +++ b/pyanaconda/core/constants.py @@ -269,6 +269,9 @@ class SecretStatus(Enum): IPMI_ABORTED = 0x9 # installation finished unsuccessfully, due to some non-exn error IPMI_FAILED = 0xA # installation hit an exception +# Wayland socket name to use +WAYLAND_SOCKET_NAME = "wl-sysinstall-0" + # X display number to use X_DISPLAY_NUMBER = 1 diff --git a/pyanaconda/core/util.py b/pyanaconda/core/util.py index 81a040d1938..08e8c4626fc 100644 --- a/pyanaconda/core/util.py +++ b/pyanaconda/core/util.py @@ -255,7 +255,7 @@ def sigusr1_preexec(): log.debug("Exception handler test suspended to prevent accidental activation by " "delayed Xorg start. Next SIGUSR1 will be handled as delayed Xorg start.") # Raise an exception to notify the caller that things went wrong. This affects - # particularly pyanaconda.display.do_startup_x11_actions(), where the window manager + # particularly pyanaconda.display.do_startup_wl_actions(), where the window manager # is started immediately after this. The WM would just wait forever. raise TimeoutError("Timeout trying to start %s" % argv[0]) diff --git a/pyanaconda/display.py b/pyanaconda/display.py index c527b66b577..00219a0b9cd 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -20,7 +20,6 @@ # Author(s): Martin Kolman # import os -import subprocess import time import textwrap import pkgutil @@ -51,9 +50,9 @@ log = get_module_logger(__name__) stdout_log = get_stdout_logger() -X_TIMEOUT_ADVICE = \ +WAYLAND_TIMEOUT_ADVICE = \ "Do not load the stage2 image over a slow network link.\n" \ - "Wait longer for the X server startup with the inst.xtimeout= boot option." \ + "Wait longer for Wayland startup with the inst.xtimeout= boot option." \ "The default is 60 seconds.\n" \ "Load the stage2 image into memory with the rd.live.ram boot option to decrease access " \ "time.\n" \ @@ -183,22 +182,7 @@ def check_vnc_can_be_started(anaconda): return vnc_startup_possible, error_messages -# X11 - -def start_x11(xtimeout): - """Start the X server for the Anaconda GUI.""" - - # Start Xorg and wait for it become ready - util.startX(["Xorg", "-br", "-logfile", "/tmp/X.log", - ":%s" % constants.X_DISPLAY_NUMBER, "vt6", "-s", "1440", "-ac", - "-nolisten", "tcp", "-dpi", "96", - "-noreset"], - output_redirect=subprocess.DEVNULL, timeout=xtimeout) - - -# function to handle X startup special issues for anaconda - -def do_startup_x11_actions(): +def do_startup_wl_actions(timeout): """Start the window manager. When window manager actually connects to the X server is unknowable, but @@ -221,14 +205,35 @@ def do_startup_x11_actions(): # pylint: disable=environment-modify os.environ['XDG_CONFIG_DIRS'] = xdg_config_dirs - def x11_preexec(): + os.environ["XDG_SESSION_TYPE"] = "wayland" + + def wl_preexec(): # to set GUI subprocess SIGINT handler signal.signal(signal.SIGINT, signal.SIG_IGN) - childproc = util.startProgram(["gnome-kiosk", "--display", ":1", "--sm-disable", "--x11"], - env_add={'XDG_DATA_DIRS': xdg_data_dirs}, - preexec_fn=x11_preexec) - WatchProcesses.watch_process(childproc, "gnome-kiosk") + argv = ["/usr/libexec/anaconda/run-in-new-session", + "--user", "root", + "--service", "anaconda", + "--vt", "6", + "--session-type", "wayland", + "--session-class", "user", + "gnome-kiosk", "--sm-disable", "--wayland", "--no-x11", + "--wayland-display", constants.WAYLAND_SOCKET_NAME] + + childproc = util.startProgram(argv, env_add={'XDG_DATA_DIRS': xdg_data_dirs}, + preexec_fn=wl_preexec) + WatchProcesses.watch_process(childproc, argv[0]) + + for _i in range(0, int(timeout / 0.1)): + wl_socket_path = os.path.join(os.getenv("XDG_RUNTIME_DIR"), constants.WAYLAND_SOCKET_NAME) + if os.path.exists(wl_socket_path): + return + + time.sleep(0.1) + + WatchProcesses.unwatch_process(childproc) + childproc.terminate() + raise TimeoutError("Timeout trying to start gnome-kiosk") def set_x_resolution(runres): @@ -353,17 +358,6 @@ def setup_display(anaconda, options): for error_message in vnc_error_messages: stdout_log.warning(error_message) - # Should we try to start Xorg? - want_x = anaconda.gui_mode and not (flags.preexisting_x11 or flags.usevnc) - - # Is Xorg is actually available? - if want_x and not os.access("/usr/bin/Xorg", os.X_OK): - stdout_log.warning(_("Graphical installation is not available. " - "Starting text mode.")) - time.sleep(2) - anaconda.display_mode = constants.DisplayModes.TUI - want_x = False - if anaconda.tui_mode and flags.vncquestion: # we prefer vnc over text mode, so ask about that message = _("Text mode provides a limited set of installation " @@ -379,18 +373,17 @@ def setup_display(anaconda, options): startup_utils.check_memory(anaconda, options) # check_memory may have changed the display mode - want_x = want_x and (anaconda.gui_mode) - if want_x: + want_gui = anaconda.gui_mode and not (flags.preexisting_wayland or flags.usevnc) + if want_gui: try: - start_x11(xtimeout) - do_startup_x11_actions() + do_startup_wl_actions(xtimeout) except TimeoutError as e: - log.warning("X startup failed: %s", e) - print("\nX did not start in the expected time, falling back to text mode. There are " - "multiple ways to avoid this issue:") + log.warning("Wayland startup failed: %s", e) + print("\nWayland did not start in the expected time, falling back to text mode. " + "There are multiple ways to avoid this issue:") wrapper = textwrap.TextWrapper(initial_indent=" * ", subsequent_indent=" ", width=os.get_terminal_size().columns - 3) - for line in X_TIMEOUT_ADVICE.split("\n"): + for line in WAYLAND_TIMEOUT_ADVICE.split("\n"): print(wrapper.fill(line)) util.vtActivate(1) anaconda.display_mode = constants.DisplayModes.TUI @@ -398,8 +391,8 @@ def setup_display(anaconda, options): time.sleep(2) except (OSError, RuntimeError) as e: - log.warning("X or window manager startup failed: %s", e) - print("\nX or window manager startup failed, falling back to text mode.") + log.warning("Wayland startup failed: %s", e) + print("\nWayland startup failed, falling back to text mode.") util.vtActivate(1) anaconda.display_mode = constants.DisplayModes.TUI anaconda.gui_startup_failed = True @@ -418,7 +411,7 @@ def setup_display(anaconda, options): # if they want us to use VNC do that now if anaconda.gui_mode and flags.usevnc: vnc_server.startServer() - do_startup_x11_actions() + do_startup_wl_actions(xtimeout) # with X running we can initialize the UI interface anaconda.initialize_interface() diff --git a/pyanaconda/flags.py b/pyanaconda/flags.py index 000fd5df876..b70aa7def8e 100644 --- a/pyanaconda/flags.py +++ b/pyanaconda/flags.py @@ -35,6 +35,7 @@ def __init__(self): self.__dict__['_in_init'] = True self.usevnc = False self.vncquestion = True + self.preexisting_wayland = False self.preexisting_x11 = False self.automatedInstall = False self.eject = True diff --git a/scripts/Makefile.am b/scripts/Makefile.am index b69e97365ec..09cd9d925ec 100644 --- a/scripts/Makefile.am +++ b/scripts/Makefile.am @@ -16,7 +16,8 @@ # along with this program. If not, see . scriptsdir = $(libexecdir)/$(PACKAGE_NAME) -dist_scripts_SCRIPTS = run-anaconda anaconda-pre-log-gen log-capture start-module apply-updates +dist_scripts_SCRIPTS = run-anaconda anaconda-pre-log-gen log-capture start-module apply-updates \ + run-in-new-session dist_noinst_SCRIPTS = makeupdates makebumpver diff --git a/scripts/makeupdates b/scripts/makeupdates index 2b1dddf20e4..59728122a0b 100755 --- a/scripts/makeupdates +++ b/scripts/makeupdates @@ -35,7 +35,8 @@ RPM_RELEASE_DIR_TEMPLATE = "for_%s" SITE_PACKAGES_PATH = "./usr/lib64/python3.13/site-packages/" # Anaconda scripts that should be installed into the libexec folder -LIBEXEC_SCRIPTS = ["log-capture", "start-module", "apply-updates", "anaconda-pre-log-gen"] +LIBEXEC_SCRIPTS = ["log-capture", "start-module", "apply-updates", "anaconda-pre-log-gen", + "run-in-new-session"] # Anaconda scripts that should be installed into /usr/bin USR_BIN_SCRIPTS = ["anaconda-disable-nm-ibft-plugin", "anaconda-nm-disable-autocons"] diff --git a/scripts/run-in-new-session b/scripts/run-in-new-session new file mode 100755 index 00000000000..80aa673eb37 --- /dev/null +++ b/scripts/run-in-new-session @@ -0,0 +1,231 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2024 Red Hat, Inc. +# +# This copyrighted material is made available to anyone wishing to use, +# modify, copy, or redistribute it subject to the terms and conditions of +# the GNU General Public License v.2, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY expressed or implied, including the implied warranties 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 this program; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. Any Red Hat trademarks that are incorporated in the +# source code or documentation are not subject to the GNU General Public +# License and may only be used or replicated with the express permission of +# Red Hat, Inc. +# +# Author(s): Martin Kolman , Ray Strode +# + +import argparse +import fcntl +import pam +import pwd +import os +import signal +import struct +import subprocess +import sys + +VT_GETSTATE = 0x5603 +VT_ACTIVATE = 0x5606 +VT_OPENQRY = 0x5600 +VT_WAITACTIVE = 0x5607 +TIOCSCTTY = 0x540E + + +def is_running_in_logind_session(): + try: + with open('/proc/self/loginuid', 'r') as f: + loginuid = int(f.read().strip()) + return loginuid != 0xFFFFFFFF + except Exception as e: + # pylint: disable-next=broad-exception-raised + raise Exception(f"Error reading /proc/self/loginuid: {e}") from e + + +def find_free_vt(): + with open('/dev/tty0', 'w') as console: + result = fcntl.ioctl(console, VT_OPENQRY, struct.pack('i', 0)) + vt = struct.unpack('i', result)[0] + return vt + + +def run_program_in_new_session(arguments, pam_environment, user, service, + tty_input, tty_output, vt): + pam_handle = pam.pam() + + for key, value in pam_environment.items(): + pam_handle.putenv(f'{key}={value}') + + old_tty_input = os.fdopen(os.dup(0), 'r') + os.dup2(os.dup(tty_input.fileno()), 0) + + if not pam_handle.authenticate(user, '', service=service, call_end=False): + # pylint: disable-next=broad-exception-raised + raise Exception("Authentication failed") + + for key, value in pam_environment.items(): + pam_handle.putenv(f'{key}={value}') + + if pam_handle.open_session() != pam.PAM_SUCCESS: + # pylint: disable-next=broad-exception-raised + raise Exception("Failed to open PAM session") + + session_environment = os.environ.copy() + session_environment.update(pam_handle.getenvlist()) + + os.dup2(old_tty_input.fileno(), 0) + + user_info = pwd.getpwnam(user) + uid = user_info.pw_uid + gid = user_info.pw_gid + + old_tty_output = os.fdopen(os.dup(2), 'w') + + console = open("/dev/tty0", 'w') + + try: + old_vt = 0 + if vt: + vt_state = fcntl.ioctl(console, VT_GETSTATE, struct.pack('HHH', 0, 0, 0)) + old_vt, _, _ = struct.unpack('HHH', vt_state) + except OSError as e: + print(f"Could not read current VT: {e}", file=old_tty_output) + + pid = os.fork() + if pid == 0: + try: + os.setsid() + except OSError as e: + print(f"Could not create new pid session: {e}", file=old_tty_output) + + try: + fcntl.ioctl(tty_output, TIOCSCTTY, 1) + except OSError as e: + print(f"Could not take control of tty: {e}", file=old_tty_output) + + try: + fcntl.ioctl(console, VT_ACTIVATE, vt) + except OSError as e: + print(f"Could not change to VT {vt}: {e}", file=old_tty_output) + + try: + fcntl.ioctl(console, VT_WAITACTIVE, vt) + except OSError as e: + print(f"Could not wait for VT {vt} to change: {e}", file=old_tty_output) + + try: + os.dup2(tty_input.fileno(), 0) + os.dup2(tty_output.fileno(), 1) + os.dup2(tty_output.fileno(), 2) + except OSError as e: + print(f"Could not set up standard i/o: {e}", file=old_tty_output) + + try: + os.initgroups(user, gid) + os.setgid(gid) + os.setuid(uid) + except OSError as e: + print(f"Could not become user {user} (uid={uid}): {e}", file=old_tty_output) + + try: + os.execvpe(arguments[0], arguments, session_environment) + except OSError as e: + print(f"Could not run program \"{' '.join(arguments)}\": {e}", file=old_tty_output) + os._exit(1) + + try: + (_, exit_code) = os.waitpid(pid, 0) + except KeyboardInterrupt: + os.kill(pid, signal.SIGINT) + except OSError as e: + print(f"Could not wait for program to finish: {e}", file=old_tty_output) + + try: + if old_vt: + fcntl.ioctl(console, VT_ACTIVATE, old_vt) + fcntl.ioctl(console, VT_WAITACTIVE, old_vt) + except OSError as e: + print(f"Could not change VTs back: {e}", file=old_tty_output) + + if os.WIFEXITED(exit_code): + exit_code = os.WEXITSTATUS(exit_code) + else: + os.kill(os.getpid(), os.WTERMSIG(exit_code)) + old_tty_output.close() + console.close() + + if pam_handle.close_session() != pam.PAM_SUCCESS: + # pylint: disable-next=broad-exception-raised + raise Exception("Failed to close PAM session") + + pam_handle.end() + + return exit_code + + +def main(): + parser = argparse.ArgumentParser(description='Run a program in a PAM session with specific' + ' environment variables as a specified user.') + parser.add_argument('--user', default='root', help='Username for which to run the program') + parser.add_argument('--service', default='su-l', help='PAM service to use') + parser.add_argument('--session-type', default='x11', help='e.g., x11, wayland, or tty') + parser.add_argument('--session-class', default='user', help='e.g., greeter or user') + parser.add_argument('--session-desktop', help='desktop file id associated with session, e.g.' + ' gnome, gnome-classic, gnome-wayland') + parser.add_argument('--vt', help='VT to run on') + + args, remaining_args = parser.parse_known_args() + + if not remaining_args: + remaining_args = ["bash", "-l"] + + if not args.vt: + vt = find_free_vt() + print(f'Using VT {vt}') + else: + vt = int(args.vt) + + if is_running_in_logind_session(): + program = ['systemd-run', + f'--unit=run-in-new-session-{os.getpid()}.service', + '--pipe', + '--wait', + '-d'] + + program += [sys.executable] + program += sys.argv + subprocess.run(program, check=False) + return + + try: + tty_path = f'/dev/tty{vt}' + tty_input = open(tty_path, 'r') + tty_output = open(tty_path, 'w') + + pam_environment = {} + pam_environment['XDG_SEAT'] = "seat0" + pam_environment['XDG_SESSION_TYPE'] = args.session_type + pam_environment['XDG_SESSION_CLASS'] = args.session_class + pam_environment['XDG_SESSION_DESKTOP'] = args.session_desktop + pam_environment['XDG_VTNR'] = vt + + try: + result = run_program_in_new_session(remaining_args, pam_environment, args.user, + args.service, tty_input, tty_output, vt) + except OSError as e: + # pylint: disable-next=broad-exception-raised + raise Exception(f"Error running program \"{' '.join(remaining_args)}\": {e}") from e + tty_input.close() + tty_output.close() + sys.exit(result) + except OSError as e: + # pylint: disable-next=broad-exception-raised + raise Exception(f"Error opening tty associated with VT {vt}: {e}") from e + + +if __name__ == '__main__': + main() From 44f5b5f3b469d81a8b06cc61f8b016ff74639f81 Mon Sep 17 00:00:00 2001 From: Radek Vykydal Date: Tue, 27 Feb 2024 09:37:32 +0100 Subject: [PATCH 07/31] Add unit tests for GkKeyboardManager and its API in localization module Resolves: RHEL-38399 (cherry picked from commit 27b097e74545ccc12926018689824d155dcc1e19) --- .../localization/gk_keyboard_manager_test.py | 156 ++++++++++++++++++ .../localization/test_module_localization.py | 45 ++++- 2 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/pyanaconda_tests/modules/localization/gk_keyboard_manager_test.py diff --git a/tests/unit_tests/pyanaconda_tests/modules/localization/gk_keyboard_manager_test.py b/tests/unit_tests/pyanaconda_tests/modules/localization/gk_keyboard_manager_test.py new file mode 100644 index 00000000000..021b08ab9b9 --- /dev/null +++ b/tests/unit_tests/pyanaconda_tests/modules/localization/gk_keyboard_manager_test.py @@ -0,0 +1,156 @@ +# +# Copyright (C) 2024 Red Hat, Inc. +# +# This copyrighted material is made available to anyone wishing to use, +# modify, copy, or redistribute it subject to the terms and conditions of +# the GNU General Public License v.2, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY expressed or implied, including the implied warranties 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 this program; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. Any Red Hat trademarks that are incorporated in the +# source code or documentation are not subject to the GNU General Public +# License and may only be used or replicated with the express permission of +# Red Hat, Inc. +# +import unittest +import pytest + +from unittest.mock import patch, Mock + +from pyanaconda.modules.localization.gk_keyboard_manager import GkKeyboardManager +from pyanaconda.keyboard import KeyboardConfigError + + +LAYOUT_PROXY_MOCKS = { + "/org/gnome/Kiosk/InputSources/xkb_fr": + Mock(BackendType="xkb", BackendId="fr"), + "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik": + Mock(BackendType="xkb", BackendId="cn+mon_todo_galik"), + "/org/gnome/Kiosk/InputSources/non-xkb_fr": + Mock(BackendType="non-xkb", BackendId="fr"), + "/org/gnome/Kiosk/InputSources/Manager": + Mock(), +} + +MockedGKIS = Mock() +MockedGKIS.get_proxy = lambda object_path: LAYOUT_PROXY_MOCKS[object_path] +MockedGKIS.object_path = "/org/gnome/Kiosk" + + +@patch("pyanaconda.modules.localization.gk_keyboard_manager.GK_INPUT_SOURCES", new=MockedGKIS) +class GkKeyboardManagerTestCase(unittest.TestCase): + """Test the Gnome Kiosk keyboard manager.""" + + def test_properties_changed(self): + """Test _on_properties_changed callback""" + mocked_manager = GkKeyboardManager() + mocked_manager._proxy.InputSources = [ + "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik", + "/org/gnome/Kiosk/InputSources/xkb_fr" + ] + callback1_mock = Mock() + callback2_mock = Mock() + mocked_manager.compositor_selected_layout_changed.connect(callback1_mock) + mocked_manager.compositor_layouts_changed.connect(callback2_mock) + + object_path_mock = Mock() + object_path_mock.get_string.return_value = "/org/gnome/Kiosk/InputSources/xkb_fr" + mocked_manager._on_properties_changed( + "org.gnome.Kiosk.InputSources", + {"SelectedInputSource": object_path_mock}, + {}, + ) + callback1_mock.assert_called_once_with("fr") + callback2_mock.assert_not_called() + + mocked_manager._on_properties_changed( + "org.gnome.Kiosk.InputSources", + {"InputSources": ["/org/gnome/Kiosk/InputSources/xkb_fr"]}, + [], + ) + callback1_mock.assert_called_once_with("fr") + callback2_mock.assert_called_once_with(["fr"]) + + def test_get_compositor_selected_layout(self): + """Test the get_compositor_selected_layout method""" + mocked_manager = GkKeyboardManager() + mocked_manager._proxy.InputSources = [ + "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik", + "/org/gnome/Kiosk/InputSources/xkb_fr" + ] + + mocked_manager._proxy.SelectedInputSource = "/" + assert mocked_manager.get_compositor_selected_layout() == "" + + mocked_manager._proxy.SelectedInputSource = None + assert mocked_manager.get_compositor_selected_layout() == "" + + layout_path = "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik" + mocked_manager._proxy.SelectedInputSource = layout_path + assert mocked_manager.get_compositor_selected_layout() == "cn (mon_todo_galik)" + + def test_set_compositor_selected_layout(self): + """Test the set_compositor_selected_layout method""" + + mocked_manager = GkKeyboardManager() + mocked_manager._proxy.InputSources = [ + "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik", + "/org/gnome/Kiosk/InputSources/xkb_fr" + ] + assert mocked_manager.set_compositor_selected_layout("cn (mon_todo_galik)") is True + mocked_manager._proxy.SelectInputSource.assert_called_with( + "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik" + ) + + # non-xkb type raises exception + # (even in case there is xkb-type data for the layout) + mocked_manager._proxy.InputSources = [ + "/org/gnome/Kiosk/InputSources/non-xkb_fr", + "/org/gnome/Kiosk/InputSources/xkb_fr" + ] + with pytest.raises(KeyboardConfigError): + mocked_manager.set_compositor_selected_layout("fr") + + # Source not found + mocked_manager._proxy.InputSources = [ + "/org/gnome/Kiosk/InputSources/xkb_fr" + ] + assert mocked_manager.set_compositor_selected_layout("cn (mon_todo_galik)") is False + + def test_select_next_compositor_layout(self): + """Test the select_next_compositor_layout method""" + mocked_manager = GkKeyboardManager() + mocked_manager.select_next_compositor_layout() + mocked_manager._proxy.SelectNextInputSource.assert_called_once() + + def test_get_compositor_layouts(self): + """Test the get_compositor_layouts method""" + + mocked_manager = GkKeyboardManager() + mocked_manager._proxy.InputSources = [ + "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik", + "/org/gnome/Kiosk/InputSources/xkb_fr", + ] + assert mocked_manager.get_compositor_layouts() == ["cn (mon_todo_galik)", "fr"] + + mocked_manager._proxy.InputSources = [ + "/org/gnome/Kiosk/InputSources/non-xkb_fr", + "/org/gnome/Kiosk/InputSources/xkb_fr", + ] + with pytest.raises(KeyboardConfigError): + mocked_manager.get_compositor_layouts() + + def test_set_compositor_layouts(self): + """Test the set_compositor_layouts method""" + mocked_manager = GkKeyboardManager() + mocked_manager.set_compositor_layouts( + ["cz (qwerty)", "fi", "us (euro)", "fr"], + ["grp:alt_shift_toggle", "grp:ctrl_alt_toggle"], + ) + mocked_manager._proxy.SetInputSources.assert_called_with( + [("xkb", "cz+qwerty"), ("xkb", "fi"), ("xkb", "us+euro"), ("xkb", "fr")], + ["grp:alt_shift_toggle", "grp:ctrl_alt_toggle"], + ) diff --git a/tests/unit_tests/pyanaconda_tests/modules/localization/test_module_localization.py b/tests/unit_tests/pyanaconda_tests/modules/localization/test_module_localization.py index 00287001589..0ed2b6d2b3d 100644 --- a/tests/unit_tests/pyanaconda_tests/modules/localization/test_module_localization.py +++ b/tests/unit_tests/pyanaconda_tests/modules/localization/test_module_localization.py @@ -20,7 +20,7 @@ import unittest import langtable -from unittest.mock import patch +from unittest.mock import patch, Mock from textwrap import dedent from tests.unit_tests.pyanaconda_tests import check_kickstart_interface, \ @@ -36,6 +36,7 @@ ApplyKeyboardTask from pyanaconda.modules.common.task import TaskInterface from dasbus.typing import get_variant, Str, Bool +from dasbus.signal import Signal class LocalizationInterfaceTestCase(unittest.TestCase): @@ -392,6 +393,48 @@ def test_keyboard_kickstart4(self): """ self._test_kickstart(ks_in, ks_out) + @patch("pyanaconda.modules.localization.localization.GkKeyboardManager") + def test_compositor_layouts_api(self, gk_manager_cls): + manager_class_mock = Mock() + manager_class_mock.compositor_selected_layout_changed = Signal() + manager_class_mock.compositor_layouts_changed = Signal() + gk_manager_cls.return_value = manager_class_mock + + self.localization_module._compositor_keyboard_manager = None + manager_mock = self.localization_module.compositor_keyboard_manager + + self.localization_interface.GetCompositorSelectedLayout() + # pylint: disable=no-member + manager_mock.get_compositor_selected_layout.assert_called_once() + self.localization_interface.SetCompositorSelectedLayout("cz (qwerty)") + # pylint: disable=no-member + manager_mock.set_compositor_selected_layout.assert_called_once_with("cz (qwerty)") + self.localization_interface.SelectNextCompositorLayout() + # pylint: disable=no-member + manager_mock.select_next_compositor_layout.assert_called_once() + self.localization_interface.GetCompositorLayouts() + # pylint: disable=no-member + manager_mock.get_compositor_layouts.assert_called_once() + self.localization_interface.SetCompositorLayouts(["cz (qwerty)", "cn (mon_todo_galik)"], + ["option"]) + # pylint: disable=no-member + manager_mock.set_compositor_layouts.assert_called_once_with( + ["cz (qwerty)", "cn (mon_todo_galik)"], + ["option"] + ) + + # Test signals + callback_mock = Mock() + # pylint: disable=no-member + self.localization_interface.CompositorSelectedLayoutChanged.connect(callback_mock) + manager_class_mock.compositor_selected_layout_changed.emit("cz (qwerty)") + callback_mock.assert_called_once_with("cz (qwerty)") + + callback_mock = Mock() + # pylint: disable=no-member + self.localization_interface.CompositorLayoutsChanged.connect(callback_mock) + manager_class_mock.compositor_layouts_changed.emit(["cz (qwerty)", "cn (mon_todo_galik)"]) + callback_mock.assert_called_once_with(["cz (qwerty)", "cn (mon_todo_galik)"]) class LocalizationModuleTestCase(unittest.TestCase): """Test Localization module.""" From 145e0a0d6fce8322f38e33f7d153b2275e164905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Exp=C3=B3sito?= Date: Wed, 14 Feb 2024 15:58:10 +0100 Subject: [PATCH 08/31] Drop xrandr Anaconda uses xrandr to set the screen resolution when the boot option "inst.resolution" [1] is used. In order to be able to drop the X.Org server, use Mutter's API instead of xrandr. The kickstart equivalent option has been removed, so we don't need to care about it [2]. [1] https://anaconda-installer.readthedocs.io/en/latest/boot-options.html#inst-resolution [2] https://pykickstart.readthedocs.io/en/latest/kickstart-docs.html#xconfig Resolves: RHEL-38399 (cherry picked from commit 8b94761d7bed7e5f00dfa212ba9dd1d8cd25af37) --- anaconda.spec.in | 1 - pyanaconda/core/regexes.py | 3 + pyanaconda/display.py | 34 ++-- .../modules/common/constants/services.py | 5 + pyanaconda/mutter_display.py | 171 ++++++++++++++++++ 5 files changed, 197 insertions(+), 17 deletions(-) create mode 100644 pyanaconda/mutter_display.py diff --git a/anaconda.spec.in b/anaconda.spec.in index a8fbf0899ec..37706ce4abe 100644 --- a/anaconda.spec.in +++ b/anaconda.spec.in @@ -254,7 +254,6 @@ Requires: zram-generator # needed for proper driver disk support - if RPMs must be installed, a repo is needed Requires: createrepo_c # Display stuff moved from lorax templates -Requires: xrandr Requires: xrdb Requires: dbus-x11 Requires: gsettings-desktop-schemas diff --git a/pyanaconda/core/regexes.py b/pyanaconda/core/regexes.py index cc00702e3af..7422a3a2f0f 100644 --- a/pyanaconda/core/regexes.py +++ b/pyanaconda/core/regexes.py @@ -197,3 +197,6 @@ # Name of initramfs connection created by NM based on MAC NM_MAC_INITRAMFS_CONNECTION = re.compile(r'^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$') + +# Screen resolution format for the boot option "inst.resolution" +SCREEN_RESOLUTION_CONFIG = re.compile(r'^[0-9]+x[0-9]+$') diff --git a/pyanaconda/display.py b/pyanaconda/display.py index 00219a0b9cd..98350c6430e 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -25,6 +25,7 @@ import pkgutil import signal +from pyanaconda.mutter_display import MutterDisplay, MutterConfigError from pyanaconda.core.configuration.anaconda import conf from pyanaconda.core.path import join_paths from pyanaconda.core.process_watchers import WatchProcesses @@ -236,28 +237,21 @@ def wl_preexec(): raise TimeoutError("Timeout trying to start gnome-kiosk") -def set_x_resolution(runres): - """Set X server screen resolution. +def set_resolution(runres): + """Set the screen resolution. :param str runres: a resolution specification string """ try: log.info("Setting the screen resolution to: %s.", runres) - util.execWithRedirect("xrandr", ["-d", ":1", "-s", runres]) - except RuntimeError: - log.error("The X resolution was not set") - util.execWithRedirect("xrandr", ["-d", ":1", "-q"]) + mutter_display = MutterDisplay() + mutter_display.set_resolution(runres) + except MutterConfigError as error: + log.error("The resolution was not set: %s", error) -def do_extra_x11_actions(runres, gui_mode): - """Perform X11 actions not related to startup. - - :param str runres: a resolution specification string - :param gui_mode: an Anaconda display mode - """ - if runres and gui_mode and not flags.usevnc: - set_x_resolution(runres) - +def do_extra_x11_actions(): + """Perform X11 actions not related to startup.""" # Load the system-wide Xresources util.execWithRedirect("xrdb", ["-nocpp", "-merge", "/etc/X11/Xresources"]) start_spice_vd_agent() @@ -399,7 +393,15 @@ def setup_display(anaconda, options): time.sleep(2) if not anaconda.gui_startup_failed: - do_extra_x11_actions(options.runres, gui_mode=anaconda.gui_mode) + do_extra_x11_actions() + + if options.runres and anaconda.gui_mode and not flags.usevnc: + def on_mutter_ready(observer): + set_resolution(options.runres) + observer.disconnect() + + mutter_display = MutterDisplay() + mutter_display.on_service_ready(on_mutter_ready) if anaconda.tui_mode and anaconda.gui_startup_failed and \ flags.vncquestion and not vnc_data.enabled: diff --git a/pyanaconda/modules/common/constants/services.py b/pyanaconda/modules/common/constants/services.py index afcd3e94690..88b3e6f41d2 100644 --- a/pyanaconda/modules/common/constants/services.py +++ b/pyanaconda/modules/common/constants/services.py @@ -114,3 +114,8 @@ namespace=("org", "gnome", "Kiosk"), message_bus=SessionBus ) + +MUTTER_DISPLAY_CONFIG = DBusServiceIdentifier( + namespace=("org", "gnome", "Mutter", "DisplayConfig"), + message_bus=SessionBus +) diff --git a/pyanaconda/mutter_display.py b/pyanaconda/mutter_display.py new file mode 100644 index 00000000000..d06f216e90a --- /dev/null +++ b/pyanaconda/mutter_display.py @@ -0,0 +1,171 @@ +# +# Copyright (C) 2024 Red Hat, Inc. +# +# This copyrighted material is made available to anyone wishing to use, +# modify, copy, or redistribute it subject to the terms and conditions of +# the GNU General Public License v.2, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY expressed or implied, including the implied warranties 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 this program; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. Any Red Hat trademarks that are incorporated in the +# source code or documentation are not subject to the GNU General Public +# License and may only be used or replicated with the express permission of +# Red Hat, Inc. +# + +from dasbus.client.observer import DBusObserver +from pyanaconda.core.dbus import SessionBus +from pyanaconda.modules.common.constants.services import MUTTER_DISPLAY_CONFIG +from pyanaconda.core.regexes import SCREEN_RESOLUTION_CONFIG + + +__all__ = ['MutterDisplay', 'MutterConfigError'] + + +class MutterConfigError(Exception): + """Exception class for mutter configuration related problems""" + pass + + +class MonitorId(object): + """Collection of properties that identify a unique monitor.""" + + def __init__(self, props): + self.connector = props[0] + self.vendor = props[1] + self.product = props[2] + self.serial = props[3] + + def __eq__(self, other): + return self.connector == other.connector and \ + self.vendor == other.vendor and \ + self.product == other.product and \ + self.serial == other.serial + + +class MonitorMode(object): + """Available modes for a monitor.""" + + def __init__(self, props): + self.id = props[0] + self.width = props[1] + self.height = props[2] + self.refresh_rate = props[3] + self.preferred_scale = props[4] + self.supported_scales = props[5] + self.properties = props[6] + + +class Monitor(object): + """Represent a connected physical monitor.""" + + def __init__(self, props): + self.id = MonitorId(props[0]) + self.modes = list(map(MonitorMode, props[1])) + self.properties = props[2] + + +class LogicalMonitor(object): + """Represent the current logical monitor configuration""" + + def __init__(self, props): + self.x = props[0] + self.y = props[1] + self.scale = props[2] + self.transform = props[3] + self.primary = props[4] + self.monitor_ids = list(map(MonitorId, props[5])) + self.properties = props[6] + + +class LogicalMonitorConfig(object): + """Logical monitor configuration object""" + + def __init__(self, logical_monitor, monitors, x, y, width, height): + """Creates a LogicalMonitorConfig setting the given resolution if available.""" + self._logical_monitor = logical_monitor + self._monitors = monitors + + self.x = x + self.y = y + self.scale = logical_monitor.scale + self.transform = logical_monitor.transform + self.primary = logical_monitor.primary + + self.monitors = list() + for monitor_id in logical_monitor.monitor_ids: + connector = monitor_id.connector + mode_id = self._get_matching_monitor_mode_id(monitors, monitor_id, width, height) + self.monitors.append((connector, mode_id, {})) + + def _get_matching_monitor_mode_id(self, monitors, monitor_id, width, height): + monitor = next(filter(lambda m: m.id == monitor_id, monitors)) + for mode in monitor.modes: + if mode.width == width and mode.height == height: + return mode.id + + raise MutterConfigError('Monitor mode with selected resolution not found') + + def to_dbus(self): + return ( + self.x, + self.y, + self.scale, + self.transform, + self.primary, + self.monitors, + ) + + +class MutterDisplay(object): + """Class wrapping Mutter's display configuration API.""" + + def __init__(self): + self._proxy = MUTTER_DISPLAY_CONFIG.get_proxy() + + def on_service_ready(self, callback): + observer = DBusObserver(SessionBus, 'org.gnome.Kiosk') + observer.service_available.connect(callback) + observer.connect_once_available() + + def set_resolution(self, res_str): + """Changes the screen resolution. + + :param res_str: Screen resolution configuration with format "800x600". + :raises MutterConfigError on failure. + """ + if not self._proxy.ApplyMonitorsConfigAllowed: + raise MutterConfigError('Monitor configuration is not allowed') + + (width, height) = self._parse_resolution_str(res_str) + (serial, monitor_props, logical_monitor_props, _) = self._proxy.GetCurrentState() + + # Configuration method as described in org.gnome.Mutter.DisplayConfig.xml: + # 0: verify + # 1: temporary + # 2: persistent + persistent_config = 2 + + monitors = list(map(Monitor, monitor_props)) + logical_monitors = list(map(LogicalMonitor, logical_monitor_props)) + + # Align the monitors in a row starting at X coordinate 0 + x = 0 + + configs = list() + for logical_monitor in logical_monitors: + config = LogicalMonitorConfig(logical_monitor, monitors, x, 0, width, height) + x += width + configs.append(config.to_dbus()) + + self._proxy.ApplyMonitorsConfig(serial, persistent_config, configs, {}) + + def _parse_resolution_str(self, res_str): + if not SCREEN_RESOLUTION_CONFIG.match(res_str): + raise MutterConfigError('Invalid configuration resolution') + + [width, height] = res_str.split('x') + return (int(width, 10), int(height, 10)) From f6c76742718064dfcfe1922cdf3695efb183b2df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Exp=C3=B3sito?= Date: Wed, 14 Feb 2024 15:59:47 +0100 Subject: [PATCH 09/31] Drop xrdb It wasn't used and it won't work on Wayland. Resolves: RHEL-38399 (cherry picked from commit 846dbde3d7ee6d0ac1e677eafb563c29cb2222a7) --- anaconda.spec.in | 1 - pyanaconda/display.py | 9 --------- 2 files changed, 10 deletions(-) diff --git a/anaconda.spec.in b/anaconda.spec.in index 37706ce4abe..9a563117375 100644 --- a/anaconda.spec.in +++ b/anaconda.spec.in @@ -254,7 +254,6 @@ Requires: zram-generator # needed for proper driver disk support - if RPMs must be installed, a repo is needed Requires: createrepo_c # Display stuff moved from lorax templates -Requires: xrdb Requires: dbus-x11 Requires: gsettings-desktop-schemas Requires: nm-connection-editor diff --git a/pyanaconda/display.py b/pyanaconda/display.py index 98350c6430e..e53ffcaa417 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -250,13 +250,6 @@ def set_resolution(runres): log.error("The resolution was not set: %s", error) -def do_extra_x11_actions(): - """Perform X11 actions not related to startup.""" - # Load the system-wide Xresources - util.execWithRedirect("xrdb", ["-nocpp", "-merge", "/etc/X11/Xresources"]) - start_spice_vd_agent() - - def write_xdriver(driver, root=None): """Write the X driver.""" if root is None: @@ -393,8 +386,6 @@ def setup_display(anaconda, options): time.sleep(2) if not anaconda.gui_startup_failed: - do_extra_x11_actions() - if options.runres and anaconda.gui_mode and not flags.usevnc: def on_mutter_ready(observer): set_resolution(options.runres) From 942bd799272330bb5a077b0f7f44aa11b9d24475 Mon Sep 17 00:00:00 2001 From: Martin Kolman Date: Mon, 11 Mar 2024 17:42:42 +0100 Subject: [PATCH 10/31] Rename usevnc flag & similar variables Rename the usevnc flag to use_rd, eq. "use Remote Desktop". This should better describe what this flag means with RDP support being added & differentiates this from a possible future remote Web UI access, that is using a different technology from remote desktop systems. Resolves: RHEL-38407 (cherry picked from commit d1abe94537bc3f319fb4e2f893feeb370e26bc68) --- anaconda.py | 2 +- pyanaconda/display.py | 24 ++++++++++++------------ pyanaconda/flags.py | 4 ++-- pyanaconda/startup_utils.py | 7 ++++--- pyanaconda/ui/gui/spokes/keyboard.py | 4 ++-- pyanaconda/ui/tui/spokes/askvnc.py | 10 +++++----- 6 files changed, 26 insertions(+), 25 deletions(-) diff --git a/anaconda.py b/anaconda.py index d7990a0e444..052d73b2ac3 100755 --- a/anaconda.py +++ b/anaconda.py @@ -40,7 +40,7 @@ def exitHandler(rebootData): # pylint: disable=possibly-used-before-assignment # pylint: disable=used-before-assignment - if flags.usevnc: + if flags.use_rd: vnc.shutdownServer() # pylint: disable=possibly-used-before-assignment diff --git a/pyanaconda/display.py b/pyanaconda/display.py index e53ffcaa417..fa467c1407d 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -132,7 +132,7 @@ def ask_vnc_question(anaconda, vnc_server, message): if not anaconda.gui_mode: log.info("VNC requested via VNC question, switching Anaconda to GUI mode.") anaconda.display_mode = constants.DisplayModes.GUI - flags.usevnc = True + flags.use_rd = True vnc_server.password = vnc_data.password.value @@ -292,7 +292,7 @@ def setup_display(anaconda, options): vnc_server.timeout = xtimeout if options.vnc: - flags.usevnc = True + flags.use_rd = True if not anaconda.gui_mode: log.info("VNC requested via boot/CLI option, switching Anaconda to GUI mode.") anaconda.display_mode = constants.DisplayModes.GUI @@ -313,7 +313,7 @@ def setup_display(anaconda, options): vnc_data = VncData.from_structure(ui_proxy.Vnc) if vnc_data.enabled: - flags.usevnc = True + flags.use_rd = True if not anaconda.gui_mode: log.info("VNC requested via kickstart, switching Anaconda to GUI mode.") anaconda.display_mode = constants.DisplayModes.GUI @@ -333,19 +333,19 @@ def setup_display(anaconda, options): if "pyanaconda.ui.gui" not in mods: stdout_log.warning("Graphical user interface not available, falling back to text mode") anaconda.display_mode = constants.DisplayModes.TUI - flags.usevnc = False - flags.vncquestion = False + flags.use_rd = False + flags.rd_question = False # check if VNC can be started vnc_can_be_started, vnc_error_messages = check_vnc_can_be_started(anaconda) if not vnc_can_be_started: # VNC can't be started - disable the VNC question and log # all the errors that prevented VNC from being started - flags.vncquestion = False + flags.rd_question = False for error_message in vnc_error_messages: stdout_log.warning(error_message) - if anaconda.tui_mode and flags.vncquestion: + if anaconda.tui_mode and flags.rd_question: # we prefer vnc over text mode, so ask about that message = _("Text mode provides a limited set of installation " "options. It does not offer custom partitioning for " @@ -354,13 +354,13 @@ def setup_display(anaconda, options): ask_vnc_question(anaconda, vnc_server, message) if not vnc_data.enabled: # user has explicitly specified text mode - flags.vncquestion = False + flags.rd_question = False anaconda.log_display_mode() startup_utils.check_memory(anaconda, options) # check_memory may have changed the display mode - want_gui = anaconda.gui_mode and not (flags.preexisting_wayland or flags.usevnc) + want_gui = anaconda.gui_mode and not (flags.preexisting_wayland or flags.use_rd) if want_gui: try: do_startup_wl_actions(xtimeout) @@ -386,7 +386,7 @@ def setup_display(anaconda, options): time.sleep(2) if not anaconda.gui_startup_failed: - if options.runres and anaconda.gui_mode and not flags.usevnc: + if options.runres and anaconda.gui_mode and not flags.use_rd: def on_mutter_ready(observer): set_resolution(options.runres) observer.disconnect() @@ -395,14 +395,14 @@ def on_mutter_ready(observer): mutter_display.on_service_ready(on_mutter_ready) if anaconda.tui_mode and anaconda.gui_startup_failed and \ - flags.vncquestion and not vnc_data.enabled: + flags.rd_question and not vnc_data.enabled: message = _("X was unable to start on your machine. Would you like to start VNC to connect to " "this computer from another computer and perform a graphical installation or continue " "with a text mode installation?") ask_vnc_question(anaconda, vnc_server, message) # if they want us to use VNC do that now - if anaconda.gui_mode and flags.usevnc: + if anaconda.gui_mode and flags.use_rd: vnc_server.startServer() do_startup_wl_actions(xtimeout) diff --git a/pyanaconda/flags.py b/pyanaconda/flags.py index b70aa7def8e..289c2cd3a0d 100644 --- a/pyanaconda/flags.py +++ b/pyanaconda/flags.py @@ -33,8 +33,8 @@ def __setattr__(self, attr, val): def __init__(self): self.__dict__['_in_init'] = True - self.usevnc = False - self.vncquestion = True + self.use_rd = False + self.rd_question = True self.preexisting_wayland = False self.preexisting_x11 = False self.automatedInstall = False diff --git a/pyanaconda/startup_utils.py b/pyanaconda/startup_utils.py index 077de32f07e..627fe28b8a2 100644 --- a/pyanaconda/startup_utils.py +++ b/pyanaconda/startup_utils.py @@ -137,7 +137,7 @@ def check_memory(anaconda, options, display_mode=None): sys.exit(1) # override display mode if machine cannot nicely run X - if display_mode != DisplayModes.TUI and not flags.usevnc: + if display_mode != DisplayModes.TUI and not flags.use_rd: needed_ram = minimal_memory_needed(with_gui=True, with_squashfs=with_squashfs) log.info("check_memory(): total:%s, graphical:%s", total_ram, needed_ram) reason_args["needed_ram"] = needed_ram @@ -563,8 +563,9 @@ def initialize_default_systemd_target(text_mode): services_proxy = SERVICES.get_proxy() - if not services_proxy.DefaultTarget and (text_mode or flags.usevnc): - log.debug("no default systemd target set & in text/vnc mode - setting multi-user.target.") + if not services_proxy.DefaultTarget and (text_mode or flags.use_rd): + log.debug("no default systemd target set & in text/remote desktop mode - " + "setting multi-user.target.") services_proxy.DefaultTarget = TEXT_ONLY_TARGET diff --git a/pyanaconda/ui/gui/spokes/keyboard.py b/pyanaconda/ui/gui/spokes/keyboard.py index 848d440cc39..2f97bf80185 100644 --- a/pyanaconda/ui/gui/spokes/keyboard.py +++ b/pyanaconda/ui/gui/spokes/keyboard.py @@ -358,7 +358,7 @@ def completed(self): # Below are checks if we want users attention when the spoke wasn't confirmed (visited) # Not an issue for VNC, since VNC keymaps are weird and more on the client side. - if flags.flags.usevnc: + if flags.flags.use_rd: return True # Not an issue where system keyboard configuration is not allowed @@ -489,7 +489,7 @@ def _removeLayout(self, store, itr): def _refresh_switching_info(self): switch_options = self._l12_module.LayoutSwitchOptions - if flags.flags.usevnc: + if flags.flags.use_rd: self._layoutSwitchLabel.set_text(_("Keyboard layouts are not " "supported when using VNC.\n" "However the settings will be used " diff --git a/pyanaconda/ui/tui/spokes/askvnc.py b/pyanaconda/ui/tui/spokes/askvnc.py index b0d906c1088..1cd4dcf3925 100644 --- a/pyanaconda/ui/tui/spokes/askvnc.py +++ b/pyanaconda/ui/tui/spokes/askvnc.py @@ -66,7 +66,7 @@ def __init__(self, data, vnc_data, storage=None, payload=None, message=""): loop = App.get_event_loop() loop.register_signal_handler(ExceptionSignal, exception_msg_handler_and_exit) self._message = message - self._usevnc = False + self._use_rd = False self.initialize_done() @property @@ -89,12 +89,12 @@ def refresh(self, args=None): self.window.add_with_separator(self._container) def _use_vnc_callback(self, data): - self._usevnc = True - new_spoke = VNCPassSpoke(self.data, self.storage, self.payload, vnc_data=self.vnc_data) + self._use_rd = True + new_spoke = VNCPassSpoke(self.data, self.storage, self.payload, self.vnc_data) ScreenHandler.push_screen_modal(new_spoke) def _use_text_callback(self, data): - self._usevnc = False + self._use_rd = False def input(self, args, key): """Override input so that we can launch the VNC password spoke""" @@ -115,7 +115,7 @@ def input(self, args, key): return super().input(args, key) def apply(self): - self.vnc_data.enabled = self._usevnc + self.vnc_data.enabled = self._use_rd ui_proxy = RUNTIME.get_proxy(USER_INTERFACE) struct_vnc = VncData.to_structure(self.vnc_data) ui_proxy.Vnc = struct_vnc From d0f4f826acd418ab2569ac98a2419bfd5fda0d1e Mon Sep 17 00:00:00 2001 From: Martin Kolman Date: Tue, 27 Feb 2024 17:02:37 +0100 Subject: [PATCH 11/31] Introduce GNOME remote desktop support Add support for using GNOME remote desktop for providing remote graphical installation support via RDP. While GNOME remote desktop also supports VNC, we are not adding support for using it at this time. The main difference from VNC is that RDP requires both (RDP specific) username and password to be set and is encrypted by default (with locally generated SSL certificates). Resolves: RHEL-38407 (cherry picked from commit e45e07edc2cd23d807bac364b7f056e6337dd086) --- anaconda.spec.in | 8 +- pyanaconda/core/util.py | 6 +- pyanaconda/gnome_remote_destop.py | 225 ++++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 pyanaconda/gnome_remote_destop.py diff --git a/anaconda.spec.in b/anaconda.spec.in index 9a563117375..abfdbaac11a 100644 --- a/anaconda.spec.in +++ b/anaconda.spec.in @@ -259,6 +259,13 @@ Requires: gsettings-desktop-schemas Requires: nm-connection-editor Requires: librsvg2 Requires: gnome-kiosk +Requires: gnome-remote-desktop +# needed to generate RDP certs at runtime +Requires: openssl +# needed by GNOME kiosk but not declared a as explicit dep, +# instead expected to be declared like this according to the +# maintainers +Requires: mesa-dri-drivers Requires: brltty Requires: python3-pam # dependencies for rpm-ostree payload module @@ -285,7 +292,6 @@ Requires: python3-meh-gui >= %{mehver} Requires: python3-xkbregistry Requires: adwaita-icon-theme Requires: tecla -Requires: tigervnc-server-minimal Requires: nm-connection-editor %ifnarch s390 s390x Requires: NetworkManager-wifi diff --git a/pyanaconda/core/util.py b/pyanaconda/core/util.py index 08e8c4626fc..bfe742257bd 100644 --- a/pyanaconda/core/util.py +++ b/pyanaconda/core/util.py @@ -354,8 +354,8 @@ def execWithRedirect(command, argv, stdin=None, stdout=None, root='/', env_prune log_output=log_output, binary_output=binary_output, do_preexec=do_preexec)[0] -def execWithCapture(command, argv, stdin=None, root='/', log_output=True, filter_stderr=False, - do_preexec=True): +def execWithCapture(command, argv, stdin=None, root='/', env_prune=None, + log_output=True, filter_stderr=False, do_preexec=True): """ Run an external program and capture standard out and err. :param command: The command to run @@ -369,7 +369,7 @@ def execWithCapture(command, argv, stdin=None, root='/', log_output=True, filter """ argv = [command] + argv - return _run_program(argv, stdin=stdin, root=root, log_output=log_output, + return _run_program(argv, stdin=stdin, root=root, log_output=log_output, env_prune=env_prune, filter_stderr=filter_stderr, do_preexec=do_preexec)[1] diff --git a/pyanaconda/gnome_remote_destop.py b/pyanaconda/gnome_remote_destop.py new file mode 100644 index 00000000000..338ba85b1cf --- /dev/null +++ b/pyanaconda/gnome_remote_destop.py @@ -0,0 +1,225 @@ +# +# gnome_remote_desktop.py: GRD related installer functionality +# +# Copyright (C) 2024 Red Hat, Inc. All rights reserved. +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program. If not, see . +# + +import os +import sys +import time +from pyanaconda import network +from pyanaconda.core import util +from pyanaconda.core.util import execWithCapture, startProgram +import socket + +from pyanaconda.core.i18n import _ + +from pyanaconda.anaconda_loggers import get_stdout_logger +stdoutLog = get_stdout_logger() + +from pyanaconda.anaconda_loggers import get_module_logger +log = get_module_logger(__name__) + +OPENSSL_BINARY_PATH = "/usr/bin/openssl" + +GRD_RDP_CERT_DIR = "/root/.local/share/gnome-remote-desktop/" +GRD_RDP_CERT = "/root/.local/share/gnome-remote-desktop/rdp.crt" +GRD_RDP_CERT_KEY = "/root/.local/share/gnome-remote-desktop/rdp.key" + +GRD_BINARY_PATH = "/usr/libexec/gnome-remote-desktop-daemon" +GRD_PID = None +GRD_LOG_FILE = "/tmp/gnome-remote-desktop.log" + +grd_process = None + +# partially based on: https://copr.fedorainfracloud.org/coprs/jadahl/headless-sessions/ + + +def shutdown_server(): + """Try to shutdown running GNOME Remote Desktop instance + + Why is this function on the module level and not in the GRDServer class ? + + As the server needs to be killed from the exit handler, it would have + to somehow get to the GRD instance. Like this, it can just kill + it by calling a function of the GNOME Remote Desktop module, that + has access to the GRD process. + """ + + if grd_process is None: + log.error("Cannot shutdown GNOME Remote Desktop - process handle missing") + else: + try: + grd_process.kill() + log.info("The GNOME Remote Desktop session has been shut down.") + except SystemError as e: + log.error("Shutdown of the GNOME Remote Desktop session failed with exception:\n%s", e) + + +class GRDServer(object): + + def __init__(self, anaconda, root="/", ip=None, name=None, + rdp_username="", rdp_password=""): + self.root = root + self.ip = ip + self.rdp_username = rdp_username + self.name = name + self.rdp_password = rdp_password + self.anaconda = anaconda + self.log = get_stdout_logger() + + # check if we the needed dependencies for using the GNOME remote desktop + # & abort the installation if not + + # start by checking we have openssl available + if not os.path.exists(OPENSSL_BINARY_PATH): + stdoutLog.critical("No openssl binary found, can't generate certificates " + "for GNOME remote desktop. Aborting.") + util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) + sys.exit(1) + + # start by checking we have GNOME remote desktop available + if not os.path.exists(GRD_BINARY_PATH): + # we assume there that the main binary being present implies grdctl is there as well + stdoutLog.critical("GNOME remote desktop tooling is not available. Aborting.") + util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) + sys.exit(1) + + def _handle_rdp_certificates(self): + """Generate SSL certificate and use it for incoming RDP connection.""" + + # then create folder for the certs + os.makedirs(GRD_RDP_CERT_DIR) + # generate the certs + execWithCapture(OPENSSL_BINARY_PATH, + ["req", "-new", + "-newkey", "rsa:4096", + "-days", "720", "-nodes", "-x509", + "-subj", "/C=DE/ST=NONE/L=NONE/O=GNOME/CN=localhost", + "-out", GRD_RDP_CERT, + "-keyout", GRD_RDP_CERT_KEY] + ) + # tell GNOME remote desktop to use these certificates + self._run_grdctl(["rdp", "set-tls-cert", GRD_RDP_CERT]) + self._run_grdctl(["rdp", "set-tls-key", GRD_RDP_CERT_KEY]) + + def _set_rdp_username_and_password(self): + """Set the RDP username and password.""" + self._run_grdctl(["rdp", "set-credentials", self.rdp_username, self.rdp_password]) + # disable view only mode + self._run_grdctl(["rdp", "disable-view-only"]) + # also actually tell GNOME remote desktop that we (obviously) want to use RDP + self._run_grdctl(["rdp", "enable"]) + + def _find_network_address(self): + """Find machine IP address, so we can show it to the user.""" + + # Network may be slow. Try for 5 seconds + tries = 5 + while tries: + self.ip = network.get_first_ip_address() + if self.ip: + break + time.sleep(1) + tries -= 1 + + if not self.ip: + return + + # FIXME: resolve this somehow, + # so it does not get stuck for 2 minutes in some VMs + + if self.ip.find(':') != -1: + ipstr = "[%s]" % (self.ip,) + else: + ipstr = self.ip + + try: + hinfo = socket.gethostbyaddr(self.ip) + self.log.info(hinfo) + if len(hinfo) == 3: + # Consider as coming from a valid DNS record only if single IP is returned + if len(hinfo[2]) == 1: + self.name = hinfo[0] + except socket.herror as e: + log.debug("Exception caught trying to get host name of %s: %s", ipstr, e) + + def _run_grdctl(self, argv): + """Run grdctl in the correct environment. + + This is necessary, as grdctl requires $HOME to be pruned + or else the call might not have the desired effect. + """ + # we always run GRD in --headless mode + base_argv = ["--headless"] + # extend the base argv by the caller provided arguments + combined_argv = base_argv + argv + # make sure HOME is set to /root or else settings might not be saved + execWithCapture("grdctl", combined_argv, env_prune=['HOME']) + + def _open_grd_log_file(self): + # FIXME: redirect to journal ? + try: + fd = os.open(GRD_LOG_FILE, os.O_RDWR | os.O_CREAT) + except OSError as e: + sys.stderr.write("error opening %s: %s\n", (GRD_LOG_FILE, e)) + fd = None + + return fd + + def _start_grd_process(self): + """Start the GNOME remote desktop process.""" + try: + self.log.info("Starting GNOME remote desktop.") + global grd_process + grd_process = startProgram([GRD_BINARY_PATH, "--headless"], + stdout=self._open_grd_log_file(), + env_prune=['HOME']) + self.log.info("GNOME remote desktop is now running.") + except OSError: + stdoutLog.critical("Could not start GNOME remote desktop. Aborting.") + util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) + sys.exit(1) + + def start_grd_rdp(self): + # check if RDP user name & password are set + if not self.rdp_password or not self.rdp_username: + stdoutLog.critical("RDP user name or password not set. Aborting.") + util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) + sys.exit(1) + + self.log.info(_("Starting GNOME remote desktop in RDP mode...")) + + # looks like we have some valid credentials, lets generate certificates & + # set the credentials + self._handle_rdp_certificates() + self.log.info(_("GNOME remote desktop RDP: SSL certificates generated & set")) + self._set_rdp_username_and_password() + self.log.info(_("GNOME remote desktop RDP: user name and password set")) + + # next try to find our IP address or even the hostname + network.wait_for_connectivity() + try: + self._find_network_address() + self.log.info(_("GNOME remote desktop RDP IP: %s"), self.ip) + self.log.info(_("GNOME remote desktop RDP host name: %s"), self.name) + except (socket.herror, ValueError) as e: + stdoutLog.critical("GNOME remote desktop RDP: Could not find network address: %s", e) + util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) + sys.exit(1) + + # Lets start GRD. + self._start_grd_process() From 0ef2b736083cfd0816f77fe63b7ebf0235a89e8d Mon Sep 17 00:00:00 2001 From: Martin Kolman Date: Mon, 11 Mar 2024 17:27:20 +0100 Subject: [PATCH 12/31] Add RDP boot options & deprecate VNC boot options Add boot options for use with Remote Desktop Protocol, used to guide the installation remotely using the GUI. Also deprecate the VNC boot options. Resolves: RHEL-38407 (cherry picked from commit 3ce535159b510a8887093f3dbf111d754a3b2c8e) --- anaconda.py | 18 ++++++++++++++++ data/anaconda_options.txt | 19 +++++++++++++++-- docs/boot-options.rst | 39 ++++++++++++++++++++++++++++++++++ pyanaconda/argument_parsing.py | 6 ++++++ 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/anaconda.py b/anaconda.py index 052d73b2ac3..e850f8e0a25 100755 --- a/anaconda.py +++ b/anaconda.py @@ -170,6 +170,21 @@ def setup_environment(): os.environ["EDITOR"] = "/usr/bin/nano" +# pylint: disable=redefined-outer-name +def warn_on_deprecated_options(opts, log): + """Check if deprecated options have been used & log a warning.""" + + if opts.vnc: + log.warning("The vnc option has been deprecated, use the rdp and " + "related options instead.") + if opts.vncconnect: + log.warning("The vncconnect option has been deprecated, use the rdp and " + "related options instead.") + if opts.vncpassword: + log.warning("The vncpassword option has been deprecated, use the rdp and " + "related options instead.") + + if __name__ == "__main__": # check if the CLI help is requested and return it at once, # without importing random stuff and spamming stdout @@ -248,6 +263,9 @@ def setup_environment(): if opts.updates_url: log.info("Using updates from: %s", opts.updates_url) + # log warning when deprecated options are used + warn_on_deprecated_options(opts, log) + # print errors encountered during boot startup_utils.print_dracut_errors(stdout_log) diff --git a/data/anaconda_options.txt b/data/anaconda_options.txt index d9e1cfe09e6..c3f6e094baa 100644 --- a/data/anaconda_options.txt +++ b/data/anaconda_options.txt @@ -100,16 +100,20 @@ vnc Enable VNC-based installation. You will need to connect to the machine using a VNC client application. A VNC install implies that the installed system will boot up in runlevel 3 instead of to the graphical login screen. The VNC session will be shared. Consider setting a VNC password using the vncpassword -option. This option is not supported for live installations. +option. This option is not supported for live installations. +This option is deprecated and will be removed in future releases. Use rdp and related options +instead. vncconnect Once installation is up and running, connect to the VNC client named HOST, and optionally use port PORT. +This option is deprecated and will be removed in future releases. vncpassword Enable a password for the VNC connection. This will prevent someone from inadvertently connecting to the vnc-based installation. Requires the VNC option to be specified as well. If you have specified vncconnect the PASSWORD will not be used unless connection to host is not possible. Please note that -the password needs to be 6 to 8 characters long (limitation of the VNC protocol). +the password needs to be 6 to 8 characters long (limitation of the VNC protocol).This option is deprecated +and will be removed in future releases. Use rdp and related options instead. xdriver Use DRIVER as the X driver to use during installation as well as on the installed system. @@ -117,6 +121,17 @@ Use DRIVER as the X driver to use during installation as well as on the installe xtimeout Specify the timeout in seconds for starting X server. +rdp +Enable Remote Desktop Protocol-controlled installation. You will need to connect to the machine using an RDP +client application. An RDP install implies that the installed system will boot up in in multiuser.target +instead of to the graphical login screen. Multiple RDP clients can connect. When using rdp you also need to set +RDP username and password using the rdp.username and rdp.password options. + +rdp.username +Set password for the RDP session. To enable RDP access, also use the rdp and rdp.password options. + +rdp.password +Set password for the RDP session. To enable RDP access, also use the rdp and rdp.username options. keymap Keyboard layout to use during installation and on the installed system. Valid KEYMAP values are those which can be used for the keyboard kickstart command. diff --git a/docs/boot-options.rst b/docs/boot-options.rst index 546d6b3b90f..26827979220 100644 --- a/docs/boot-options.rst +++ b/docs/boot-options.rst @@ -514,6 +514,36 @@ Specify screen size for the installer. Use format nxm, where n is the number of horizontal pixels, m the number of vertical pixels. The lowest supported resolution is 800x600. +.. inst.rdp: + +inst.rdp +^^^^^^^^ + +Enable Remote Desktop Protocol-controlled installation. You will need to connect to +the machine using an RDP client application. An RDP install implies that the installed +system will boot up in in multiuser.target instead of to the graphical login screen. + +Multiple RDP clients can connect. + +When using ``inst.rdp``, you also need to set RDP username and password using the +``inst.rdp.username`` and ``inst.rdp.password`` boot options. + +.. inst.rdp.username: + +inst.rdp.username +^^^^^^^^^^^^^^^^^ + +Set username for the RDP session. To enable RDP access, also use the +``inst.rdp`` and ``inst.rdp.password`` boot options. + +.. inst.rdp.password: + +inst.rdp.password +^^^^^^^^^^^^^^^^^ + +Set password for the RDP session. To enable RDP access, also use the +``inst.rdp`` and ``inst.rdp.username`` boot options. + .. inst.vnc: inst.vnc @@ -525,6 +555,10 @@ may connect. A system installed with VNC will start in text mode (runlevel 3). +This option is deprecated and will be removed in future releases. +Use ``inst.rdp`` instead. + + .. inst.vncpassword: inst.vncpassword @@ -532,6 +566,9 @@ inst.vncpassword Set a password on the VNC server used by the installer. +This option is deprecated and will be removed in future releases. +Use ``inst.rdp.password`` and related boot options instead. + .. inst.vncconnect: inst.vncconnect @@ -543,6 +580,8 @@ inst.vncconnect Use with ``vncviewer -listen``. +This option is deprecated and will be removed in future releases. + .. inst.xdriver: inst.xdriver diff --git a/pyanaconda/argument_parsing.py b/pyanaconda/argument_parsing.py index 0e6ad26366b..f6429227999 100644 --- a/pyanaconda/argument_parsing.py +++ b/pyanaconda/argument_parsing.py @@ -500,6 +500,12 @@ def __call__(self, parser, namespace, values, _option_string=None): default=None, metavar="DRIVER", help=help_parser.help_text("xdriver")) ap.add_argument("--xtimeout", dest="xtimeout", action="store", type=int, default=X_TIMEOUT, metavar="TIMEOUT_IN_SECONDS", help=help_parser.help_text("xtimeout")) + ap.add_argument("--rdp", action="store_true", default=False, dest="rdp_enabled", + help=help_parser.help_text("rdp")) + ap.add_argument("--rdp.username", default="", metavar="USERNAME", dest="rdp_username", + help=help_parser.help_text("rdp.username")) + ap.add_argument("--rdp.password", default="", metavar="PASSWORD", dest="rdp_password", + help=help_parser.help_text("rdp.password")) # Language ap.add_argument("--keymap", metavar="KEYMAP", help=help_parser.help_text("keymap")) From 94b2c4820904cc401c20e73883b305c5fb668a89 Mon Sep 17 00:00:00 2001 From: Martin Kolman Date: Tue, 12 Mar 2024 18:51:03 +0100 Subject: [PATCH 13/31] Replace VNC support with GNOME remote desktop Rebuild the original TUI Ask VNC spoke to a more generic "Ask Remote Desktop" spoke, supporting RDP. Replace the Tiger VNC provided VNC support with GNOME remote desktop provided RDP support & remove Tiger VNC support. Also log a warning if the old VNC options are used & these options will now no longer have an effect. Remove module from the code so it should be easier for backporting to rhel-10. However, we should replace Vnc module with Rdp module in later commit. Resolves: RHEL-38407 (merge of commit 883d6d040f35e1a7b5706570a6e304432b1d512c) (merge of commit da6f157df1ca8040a9b6a2085857e5fb8d8bf4ea) --- anaconda.py | 4 +- pyanaconda/core/constants.py | 2 +- pyanaconda/display.py | 232 +++++++------ pyanaconda/gnome_remote_destop.py | 7 +- pyanaconda/startup_utils.py | 2 +- .../ui/tui/spokes/{askvnc.py => askrd.py} | 146 ++++++--- pyanaconda/vnc.py | 309 ------------------ .../pyanaconda_tests/test_simple_import.py | 2 +- 8 files changed, 232 insertions(+), 472 deletions(-) rename pyanaconda/ui/tui/spokes/{askvnc.py => askrd.py} (55%) delete mode 100644 pyanaconda/vnc.py diff --git a/anaconda.py b/anaconda.py index e850f8e0a25..0a62d9d2e24 100755 --- a/anaconda.py +++ b/anaconda.py @@ -41,7 +41,7 @@ def exitHandler(rebootData): # pylint: disable=possibly-used-before-assignment # pylint: disable=used-before-assignment if flags.use_rd: - vnc.shutdownServer() + gnome_remote_destop.shutdown_server() # pylint: disable=possibly-used-before-assignment # pylint: disable=used-before-assignment @@ -292,7 +292,7 @@ def warn_on_deprecated_options(opts, log): opts.display_mode = constants.DisplayModes.TUI opts.noninteractive = True - from pyanaconda import vnc + from pyanaconda import gnome_remote_destop from pyanaconda import kickstart # we are past the --version and --help shortcut so we can import display & # startup_utils, which import Blivet, without slowing down anything critical diff --git a/pyanaconda/core/constants.py b/pyanaconda/core/constants.py index 2f7dd28236a..1c676909411 100644 --- a/pyanaconda/core/constants.py +++ b/pyanaconda/core/constants.py @@ -84,7 +84,7 @@ DRACUT_SHUTDOWN_EJECT = "/run/initramfs/usr/lib/dracut/hooks/shutdown/99anaconda-eject.sh" # VNC questions -USEVNC = N_("Start VNC") +USERDP = N_("Use graphical mode via Remote Desktop Protocol") USETEXT = N_("Use text mode") # Quit message diff --git a/pyanaconda/display.py b/pyanaconda/display.py index fa467c1407d..3ce3fdde9e4 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -31,13 +31,11 @@ from pyanaconda.core.process_watchers import WatchProcesses from pyanaconda import startup_utils from pyanaconda.core import util, constants, hw -from pyanaconda import vnc +from pyanaconda.gnome_remote_destop import GRDServer from pyanaconda.core.i18n import _ from pyanaconda.flags import flags -from pyanaconda.modules.common.constants.objects import USER_INTERFACE -from pyanaconda.modules.common.constants.services import NETWORK, RUNTIME -from pyanaconda.modules.common.structures.vnc import VncData -from pyanaconda.ui.tui.spokes.askvnc import AskVNCSpoke +from pyanaconda.modules.common.constants.services import NETWORK +from pyanaconda.ui.tui.spokes.askrd import AskRDSpoke, RDPAuthSpoke from pyanaconda.ui.tui import tui_quit_callback # needed for checking if the pyanaconda.ui.gui modules are available import pyanaconda.ui @@ -60,7 +58,6 @@ "Enforce text mode when installing from remote media with the inst.text boot option." # on RHEL also: "Use the customer portal download URL in ilo/drac devices for greater speed." - def start_user_systemd(): """Start the user instance of systemd. @@ -105,85 +102,106 @@ def start_spice_vd_agent(): log.info("Started spice-vdagent.") -# VNC +# RDP -def ask_vnc_question(anaconda, vnc_server, message): - """ Ask the user if TUI or GUI-over-VNC should be started. +def ask_rd_question(anaconda, grd_server, message): + """ Ask the user if TUI or GUI-over-RDP should be started. :param anaconda: instance of the Anaconda class - :param vnc_server: instance of the VNC server object + :param grd_server: instance of the GRD server object :param str message: a message to show to the user together with the question + :return: if remote desktop should be used + :rtype: bool """ App.initialize() loop = App.get_event_loop() loop.set_quit_callback(tui_quit_callback) # Get current vnc data from DBUS - ui_proxy = RUNTIME.get_proxy(USER_INTERFACE) - vnc_data = VncData.from_structure(ui_proxy.Vnc) - spoke = AskVNCSpoke(anaconda.ksdata, vnc_data, message=message) + spoke = AskRDSpoke(anaconda.ksdata, message=message) ScreenHandler.schedule_screen(spoke) App.run() - # Update vnc data from DBUS - vnc_data = VncData.from_structure(ui_proxy.Vnc) - - if vnc_data.enabled: + if spoke.use_remote_desktop: if not anaconda.gui_mode: - log.info("VNC requested via VNC question, switching Anaconda to GUI mode.") + log.info("RDP requested via RDP question, switching Anaconda to GUI mode.") anaconda.display_mode = constants.DisplayModes.GUI flags.use_rd = True - vnc_server.password = vnc_data.password.value + grd_server.rdp_username = spoke.rdp_username + grd_server.rdp_password = spoke.rdp_password + return spoke.use_remote_desktop -def check_vnc_can_be_started(anaconda): - """Check if we can start VNC in the current environment. - :returns: if VNC can be started and list of possible reasons - why VNC can't be started +def ask_for_rd_credentials(anaconda, grd_server, username=None, password=None): + """ Ask the user to provide RDP credentials interactively. + + :param anaconda: instance of the Anaconda class + :param grd_server: instance of the GRD server object + :param str username: user set username (if any) + :param str password: user set password (if any) + """ + App.initialize() + loop = App.get_event_loop() + loop.set_quit_callback(tui_quit_callback) + spoke = RDPAuthSpoke(anaconda.ksdata, username=username, password=password) + ScreenHandler.schedule_screen(spoke) + App.run() + + log.info("RDP credentials set") + anaconda.display_mode = constants.DisplayModes.GUI + flags.use_rd = True + grd_server.rdp_username = spoke._username + grd_server.rdp_password = spoke._password + + +def check_rd_can_be_started(anaconda): + """Check if we can start an RDP session in the current environment. + + :returns: if RDP session can be started and list of possible reasons + why the session can't be started :rtype: (boot, list) """ error_messages = [] - vnc_startup_possible = True + rd_startup_possible = True - # disable VNC over text question when not enough memory is available + # disable remote desktop over text question when not enough memory is available min_gui_ram = hw.minimal_memory_needed(with_gui=True) if blivet.util.total_memory() < min_gui_ram: - error_messages.append("Not asking for VNC because current memory (%d) < MIN_GUI_RAM (%d)" % + error_messages.append("Not asking for remote desktop session because current memory " + "(%d) < MIN_GUI_RAM (%d)" % (blivet.util.total_memory(), min_gui_ram)) - vnc_startup_possible = False - - # if running in text mode, we might sometimes skip showing the VNC question - if anaconda.tui_mode: - # disable VNC question if we were explicitly asked for text mode in kickstart - ui_proxy = RUNTIME.get_proxy(USER_INTERFACE) - if ui_proxy.DisplayModeTextKickstarted: - error_messages.append( - "Not asking for VNC because text mode was explicitly asked for in kickstart" - ) - vnc_startup_possible = False - # disable VNC question if text mode is requested and this is an automated kickstart - # installation - elif flags.automatedInstall: - error_messages.append("Not asking for VNC because of an automated install") - vnc_startup_possible = False - - # disable VNC question if we don't have network + rd_startup_possible = False + + # disable remote desktop question if text mode is requested and this is a ks install + if anaconda.tui_mode and flags.automatedInstall: + error_messages.append( + "Not asking for remote desktop session because of an automated install" + ) + rd_startup_possible = False + + # disable remote desktop question if we were explicitly asked for text in kickstart + if anaconda.display_mode == constants.DisplayModes.TUI: + error_messages.append("Not asking for remote desktop session because text mode " + "was explicitly asked for in kickstart") + rd_startup_possible = False + + # disable remote desktop question if we don't have network network_proxy = NETWORK.get_proxy() if not network_proxy.IsConnecting() and not network_proxy.Connected: error_messages.append("Not asking for VNC because we don't have a network") - vnc_startup_possible = False + rd_startup_possible = False - # disable VNC question if we don't have Xvnc - if not os.access('/usr/bin/Xvnc', os.X_OK): - error_messages.append("Not asking for VNC because we don't have Xvnc") - vnc_startup_possible = False + # disable remote desktop question if we don't have GNOME remote desktop + if not os.access('/usr/bin/grdctl', os.X_OK): + error_messages.append("Not asking for remote desktop because we don't have grdctl") + rd_startup_possible = False - return vnc_startup_possible, error_messages + return rd_startup_possible, error_messages -def do_startup_wl_actions(timeout): +def do_startup_wl_actions(timeout, headless=False, headless_resolution=None): """Start the window manager. When window manager actually connects to the X server is unknowable, but @@ -193,6 +211,9 @@ def do_startup_wl_actions(timeout): fingers crossed. Add XDG_DATA_DIRS to the environment to pull in our overridden schema files. + + :param bool headless: start a headless session (used for RDP access) + :param str headless_resolution: headless virtual monitor resolution in WxH format """ datadir = os.environ.get('ANACONDA_DATADIR', '/usr/share/anaconda') if 'XDG_DATA_DIRS' in os.environ: @@ -205,21 +226,39 @@ def do_startup_wl_actions(timeout): xdg_config_dirs = datadir + ':' + os.environ['XDG_CONFIG_DIRS'] # pylint: disable=environment-modify os.environ['XDG_CONFIG_DIRS'] = xdg_config_dirs - os.environ["XDG_SESSION_TYPE"] = "wayland" def wl_preexec(): # to set GUI subprocess SIGINT handler signal.signal(signal.SIGINT, signal.SIG_IGN) + # lets compile arguments for the run-in-new-session script argv = ["/usr/libexec/anaconda/run-in-new-session", "--user", "root", "--service", "anaconda", - "--vt", "6", "--session-type", "wayland", - "--session-class", "user", - "gnome-kiosk", "--sm-disable", "--wayland", "--no-x11", - "--wayland-display", constants.WAYLAND_SOCKET_NAME] + "--session-class", "user"] + + if headless: + # headless (remote connection) - stay on VT1 where connection info is + argv.extend(["--vt", "1"]) + else: + # local display - switch to VT6 & show GUI there + argv.extend(["--vt", "6"]) + + # add the generic GNOME Kiosk invocation + argv.extend(["gnome-kiosk", "--sm-disable", + "--wayland", "--no-x11", + "--wayland-display", constants.WAYLAND_SOCKET_NAME]) + + # remote access needs gnome-kiosk to start in headless mode & + # configure a virtual monitor + if headless: + # check virtual monitor resolution has been set + if headless_resolution is None: + # use default value + headless_resolution = "1280x1024" + argv.extend(["--headless", "--virtual-monitor", headless_resolution]) childproc = util.startProgram(argv, env_add={'XDG_DATA_DIRS': xdg_data_dirs}, preexec_fn=wl_preexec) @@ -287,46 +326,22 @@ def setup_display(anaconda, options): log.warning("invalid inst.xtimeout option value: %s", options.xtimeout) xtimeout = constants.X_TIMEOUT - vnc_server = vnc.VncServer() # The vnc Server object. - vnc_server.anaconda = anaconda - vnc_server.timeout = xtimeout + grd_server = GRDServer(anaconda) # The RDP server object + rdp_credentials_sufficient = False - if options.vnc: + if options.rdp_enabled: flags.use_rd = True if not anaconda.gui_mode: - log.info("VNC requested via boot/CLI option, switching Anaconda to GUI mode.") + log.info("RDP requested via boot/CLI option, switching Anaconda to GUI mode.") anaconda.display_mode = constants.DisplayModes.GUI - vnc_server.password = options.vncpassword - - # Only consider vncconnect when vnc is a param - if options.vncconnect: - cargs = options.vncconnect.split(":") - vnc_server.vncconnecthost = cargs[0] - if len(cargs) > 1 and len(cargs[1]) > 0: - if len(cargs[1]) > 0: - vnc_server.vncconnectport = cargs[1] + grd_server.rdp_username = options.rdp_username + grd_server.rdp_password = options.rdp_password + # note if we have both set + rdp_credentials_sufficient = options.rdp_username and options.rdp_password if options.xdriver: write_xdriver(options.xdriver, root="/") - ui_proxy = RUNTIME.get_proxy(USER_INTERFACE) - vnc_data = VncData.from_structure(ui_proxy.Vnc) - - if vnc_data.enabled: - flags.use_rd = True - if not anaconda.gui_mode: - log.info("VNC requested via kickstart, switching Anaconda to GUI mode.") - anaconda.display_mode = constants.DisplayModes.GUI - - if vnc_server.password == "": - vnc_server.password = vnc_data.password.value - - if vnc_server.vncconnecthost == "": - vnc_server.vncconnecthost = vnc_data.host - - if vnc_server.vncconnectport == "": - vnc_server.vncconnectport = vnc_data.port - # check if GUI without WebUI if anaconda.gui_mode and not anaconda.is_webui_supported: mods = (tup[1] for tup in pkgutil.iter_modules(pyanaconda.ui.__path__, "pyanaconda.ui.")) @@ -336,23 +351,32 @@ def setup_display(anaconda, options): flags.use_rd = False flags.rd_question = False - # check if VNC can be started - vnc_can_be_started, vnc_error_messages = check_vnc_can_be_started(anaconda) - if not vnc_can_be_started: - # VNC can't be started - disable the VNC question and log - # all the errors that prevented VNC from being started + # check if remote desktop mode can be started + rd_can_be_started, rd_error_messages = check_rd_can_be_started(anaconda) + + if rd_can_be_started: + # if remote desktop can be started & only inst.rdp + # or inst.rdp and insufficient credentials are provided + # via boot options, ask interactively. + if options.rdp_enabled and not rdp_credentials_sufficient: + ask_for_rd_credentials(anaconda, + grd_server, + options.rdp_username, + options.rdp_password) + else: + # RDP can't be started - disable the RDP question and log + # all the errors that prevented RDP from being started flags.rd_question = False - for error_message in vnc_error_messages: + for error_message in rd_error_messages: stdout_log.warning(error_message) if anaconda.tui_mode and flags.rd_question: - # we prefer vnc over text mode, so ask about that + # we prefer remote desktop over text mode, so ask about that message = _("Text mode provides a limited set of installation " "options. It does not offer custom partitioning for " "full control over the disk layout. Would you like " - "to use VNC mode instead?") - ask_vnc_question(anaconda, vnc_server, message) - if not vnc_data.enabled: + "to use remote graphical access via the RDP protocol instead?") + if not ask_rd_question(anaconda, grd_server, message): # user has explicitly specified text mode flags.rd_question = False @@ -394,17 +418,17 @@ def on_mutter_ready(observer): mutter_display = MutterDisplay() mutter_display.on_service_ready(on_mutter_ready) - if anaconda.tui_mode and anaconda.gui_startup_failed and \ - flags.rd_question and not vnc_data.enabled: + if anaconda.tui_mode and anaconda.gui_startup_failed and flags.rd_question: + message = _("X was unable to start on your machine. Would you like to start VNC to connect to " "this computer from another computer and perform a graphical installation or continue " "with a text mode installation?") - ask_vnc_question(anaconda, vnc_server, message) + ask_rd_question(anaconda, grd_server, message) - # if they want us to use VNC do that now + # if they want us to use RDP do that now if anaconda.gui_mode and flags.use_rd: - vnc_server.startServer() - do_startup_wl_actions(xtimeout) + do_startup_wl_actions(xtimeout, headless=True, headless_resolution=options.runres) + grd_server.start_grd_rdp() # with X running we can initialize the UI interface anaconda.initialize_interface() diff --git a/pyanaconda/gnome_remote_destop.py b/pyanaconda/gnome_remote_destop.py index 338ba85b1cf..3813a1edd6a 100644 --- a/pyanaconda/gnome_remote_destop.py +++ b/pyanaconda/gnome_remote_destop.py @@ -20,17 +20,16 @@ import os import sys import time +import socket from pyanaconda import network from pyanaconda.core import util from pyanaconda.core.util import execWithCapture, startProgram -import socket from pyanaconda.core.i18n import _ -from pyanaconda.anaconda_loggers import get_stdout_logger -stdoutLog = get_stdout_logger() +from pyanaconda.anaconda_loggers import get_stdout_logger, get_module_logger -from pyanaconda.anaconda_loggers import get_module_logger +stdoutLog = get_stdout_logger() log = get_module_logger(__name__) OPENSSL_BINARY_PATH = "/usr/bin/openssl" diff --git a/pyanaconda/startup_utils.py b/pyanaconda/startup_utils.py index 627fe28b8a2..3b39000c505 100644 --- a/pyanaconda/startup_utils.py +++ b/pyanaconda/startup_utils.py @@ -255,7 +255,7 @@ def prompt_for_ssh(options): if options.ksfile: return False - if options.vnc: + if options.rdp: return False # Do some work here to get the ip addr / hostname to pass diff --git a/pyanaconda/ui/tui/spokes/askvnc.py b/pyanaconda/ui/tui/spokes/askrd.py similarity index 55% rename from pyanaconda/ui/tui/spokes/askvnc.py rename to pyanaconda/ui/tui/spokes/askrd.py index 1cd4dcf3925..4c6428b5dba 100644 --- a/pyanaconda/ui/tui/spokes/askvnc.py +++ b/pyanaconda/ui/tui/spokes/askrd.py @@ -1,6 +1,8 @@ -# Ask vnc text spoke +# Ask Remote Desktop text spoke # -# Copyright (C) 2012 Red Hat, Inc. +# Asks the user if a text mode or remote desktop based access should be used. +# +# Copyright (C) 2024 Red Hat, Inc. # # This copyrighted material is made available to anyone wishing to use, # modify, copy, or redistribute it subject to the terms and conditions of @@ -19,11 +21,8 @@ import sys from pyanaconda.core.configuration.anaconda import conf -from pyanaconda.modules.common.constants.objects import USER_INTERFACE -from pyanaconda.modules.common.constants.services import RUNTIME -from pyanaconda.modules.common.structures.vnc import VncData from pyanaconda.ui.tui.spokes import NormalTUISpoke -from pyanaconda.core.constants import USEVNC, USETEXT, QUIT_MESSAGE +from pyanaconda.core.constants import USERDP, USETEXT, QUIT_MESSAGE from pyanaconda.core.i18n import N_, _ from pyanaconda.ui.tui import exception_msg_handler from pyanaconda.core.util import execWithRedirect, ipmi_abort @@ -44,18 +43,17 @@ def exception_msg_handler_and_exit(signal, data): sys.exit(1) -class AskVNCSpoke(NormalTUISpoke): +class AskRDSpoke(NormalTUISpoke): """ - .. inheritance-diagram:: AskVNCSpoke + .. inheritance-diagram:: AskRDPSpoke :parts: 3 """ - title = N_("VNC") + title = N_("RDP") # This spoke is kinda standalone, not meant to be used with a hub # We pass in some fake data just to make our parents happy - def __init__(self, data, vnc_data, storage=None, payload=None, message=""): + def __init__(self, data, storage=None, payload=None, message=""): super().__init__(data, storage, payload) - self.vnc_data = vnc_data self.input_required = True self.initialize_start() self._container = None @@ -66,9 +64,26 @@ def __init__(self, data, vnc_data, storage=None, payload=None, message=""): loop = App.get_event_loop() loop.register_signal_handler(ExceptionSignal, exception_msg_handler_and_exit) self._message = message + self._rdp_username = "" + self._rdp_password = "" self._use_rd = False self.initialize_done() + @property + def use_remote_desktop(self): + """Should a remote desktop solution be used instead of text mode ?""" + return self._use_rd + + @property + def rdp_username(self): + """User provided RDP user name (if any).""" + return self._rdp_username + + @property + def rdp_password(self): + """User provided RDP password (if any).""" + return self._rdp_password + @property def indirect(self): return True @@ -81,23 +96,25 @@ def refresh(self, args=None): self._container = ListColumnContainer(1, spacing=1) # choices are - # USE VNC - self._container.add(TextWidget(_(USEVNC)), self._use_vnc_callback) + # USE RDP + self._container.add(TextWidget(_(USERDP)), self._use_rdp_callback) # USE TEXT self._container.add(TextWidget(_(USETEXT)), self._use_text_callback) self.window.add_with_separator(self._container) - def _use_vnc_callback(self, data): + def _use_rdp_callback(self, data): self._use_rd = True - new_spoke = VNCPassSpoke(self.data, self.storage, self.payload, self.vnc_data) - ScreenHandler.push_screen_modal(new_spoke) + new_rdp_spoke = RDPAuthSpoke(self.data) + ScreenHandler.push_screen_modal(new_rdp_spoke) + self._rdp_username = new_rdp_spoke._username + self._rdp_password = new_rdp_spoke._password def _use_text_callback(self, data): self._use_rd = False def input(self, args, key): - """Override input so that we can launch the VNC password spoke""" + """Override input so that we can launch the RDP user name & password spoke""" if self._container.process_user_input(key): self.apply() return InputState.PROCESSED_AND_CLOSE @@ -115,28 +132,28 @@ def input(self, args, key): return super().input(args, key) def apply(self): - self.vnc_data.enabled = self._use_rd - ui_proxy = RUNTIME.get_proxy(USER_INTERFACE) - struct_vnc = VncData.to_structure(self.vnc_data) - ui_proxy.Vnc = struct_vnc + pass -class VNCPassSpoke(NormalTUISpoke): +class RDPAuthSpoke(NormalTUISpoke): """ .. inheritance-diagram:: VNCPassSpoke :parts: 3 """ - def __init__(self, data, storage, payload, message=None, vnc_data=None): - super().__init__(data, storage, payload) - self.vnc_data = vnc_data - self.title = N_("VNC Password") - self._password = "" - if message: - self._message = message + def __init__(self, data, username=None, password=None): + super().__init__(data, storage=None, payload=None) + self.title = N_("RDP User name & Password") + + if username is not None: + self._username = username + else: + self._username = "" + + if password is not None: + self._password = password else: - self._message = _("Please provide VNC password (must be six to eight characters long).\n" - "You will have to type it twice. Leave blank for no password") + self._password = "" @property def indirect(self): @@ -144,27 +161,59 @@ def indirect(self): @property def completed(self): - return True # We're always complete + return True # We're always complete def refresh(self, args=None): super().refresh(args) - self.window.add_with_separator(TextWidget(self._message)) + self.window.add_with_separator(TextWidget(self.message)) + + @property + def message(self): + text = "" + if not self._username and not self._password: + text = _("Please provide RDP user name & password.") + elif self._username: + text = _("Please provide RDP password.") + else: + text = _("Please provide RDP user name.") + + # if we want the password, add a note about typing it twice + if not self._password: + text = text + "\n" + _("You will have to type the password twice.") + + return text def prompt(self, args=None): """Override prompt as password typing is special.""" - p1 = self.get_user_input(_("Password: "), True) - p2 = self.get_user_input(_("Password (confirm): "), True) - - if p1 != p2: - self._print_error_and_redraw(_("Passwords do not match!")) - elif 0 < len(p1) < 6: - self._print_error_and_redraw((_("The password must be at least " - "six characters long."))) - elif len(p1) > 8: - self._print_error_and_redraw(_("The password cannot be more than " - "eight characters long.")) - else: - self._password = p1 + # first make sure username is set + if not self._username: + username = self.get_user_input(_("User name: "), False) + if username: + self._username = username + else: + self._print_error_and_redraw(_("User name not set!")) + return None + + # next try to get the password + if not self._password: + p1 = self.get_user_input(_("Password: "), True) + p2 = self.get_user_input(_("Password (confirm): "), True) + + if p1 != p2: + self._print_error_and_redraw(_("Passwords do not match!")) + return None + elif not p1: + self._print_error_and_redraw((_("The password must not be empty."))) + return None + elif 0 < len(p1) < 6: + self._print_error_and_redraw((_("The password must be at least " + "six characters long."))) + return None + else: + self._password = p1 + + # do we finally have everything ? + if self._username and self._password: self.apply() self.close() @@ -176,7 +225,4 @@ def _print_error_and_redraw(self, msg): self.redraw() def apply(self): - self.vnc_data.password.set_secret(self._password) - ui_proxy = RUNTIME.get_proxy(USER_INTERFACE) - struct_vnc = VncData.to_structure(self.vnc_data) - ui_proxy.Vnc = struct_vnc + pass diff --git a/pyanaconda/vnc.py b/pyanaconda/vnc.py deleted file mode 100644 index dffae2cf480..00000000000 --- a/pyanaconda/vnc.py +++ /dev/null @@ -1,309 +0,0 @@ -# -# vnc.py: VNC related installer functionality -# -# Copyright (C) 2004, 2007 Red Hat, Inc. All rights reserved. -# -# This program 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 2 of the License, or -# (at your option) any later version. -# -# This program 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 this program. If not, see . -# - -import os -import sys -import time -from pyanaconda import network -from pyanaconda.core import util, constants -from pyanaconda.core.product import get_product_name, get_product_version -import socket -import subprocess - -from pyanaconda.core.i18n import _, P_ -from pyanaconda.modules.common.constants.objects import USER_INTERFACE -from pyanaconda.modules.common.constants.services import RUNTIME -from pyanaconda.modules.common.structures.vnc import VncData -from pyanaconda.ui.tui import tui_quit_callback -from pyanaconda.ui.tui.spokes.askvnc import VNCPassSpoke - -from simpleline import App -from simpleline.render.screen_handler import ScreenHandler - -from pyanaconda.anaconda_loggers import get_stdout_logger -stdoutLog = get_stdout_logger() - -from pyanaconda.anaconda_loggers import get_module_logger -log = get_module_logger(__name__) - -XVNC_BINARY_NAME = "Xvnc" - - -def shutdownServer(): - """Try to shutdown any running XVNC server - - Why is this function on the module level and not in the VncServer class ? - - As the server needs to be killed from the exit handler, it would have - to somehow get to the VncServer instance. Like this, it can just kill - it by calling a function of the vnc module. - """ - try: - util.execWithCapture("killall", [XVNC_BINARY_NAME], do_preexec=False) - log.info("The XVNC server has been shut down.") - except OSError as e: - log.error("Shutdown of the XVNC server failed with exception:\n%s", e) - - -class VncServer(object): - - def __init__(self, root="/", ip=None, name=None, - password="", vncconnecthost="", - vncconnectport="", log_file="/tmp/vncserver.log", - pw_file="/tmp/vncpassword", timeout=constants.X_TIMEOUT): - self.root = root - self.ip = ip - self.name = name - self.password = password - self.vncconnecthost = vncconnecthost - self.vncconnectport = vncconnectport - self.log_file = log_file - self.pw_file = pw_file - self.timeout = timeout - self.connxinfo = None - self.anaconda = None - self.log = get_stdout_logger() - - self.desktop = _("%(productName)s %(productVersion)s installation")\ - % {'productName': get_product_name(), - 'productVersion': get_product_version()} - - def setVNCPassword(self): - """Set the vnc server password. Output to file. """ - password_string = "%s\n" % self.password - - # the -f option makes sure vncpasswd does not ask for the password again - proc = util.startProgram( - ["vncpasswd", "-f"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - - out, err = proc.communicate(password_string.encode("utf-8")) - - if proc.returncode != 0: - log.error("vncpasswd has failed with %d: %s", proc.returncode, err.decode("utf-8")) - raise OSError("Unable to set the VNC password.") - - with open(self.pw_file, "wb") as pw_file: - pw_file.write(out) - - def initialize(self): - """Here is were all the relative vars get initialized. """ - - # Network may be slow. Try for 5 seconds - tries = 5 - while tries: - self.ip = network.get_first_ip_address() - if self.ip: - break - time.sleep(1) - tries -= 1 - - if not self.ip: - return - - if self.ip.find(':') != -1: - ipstr = "[%s]" % (self.ip,) - else: - ipstr = self.ip - - try: - hinfo = socket.gethostbyaddr(self.ip) - if len(hinfo) == 3: - # Consider as coming from a valid DNS record only if single IP is returned - if len(hinfo[2]) == 1: - self.name = hinfo[0] - except socket.herror as e: - log.debug("Exception caught trying to get host name of %s: %s", ipstr, e) - - if self.name is not None and not self.name.startswith('localhost'): - self.connxinfo = "%s:%s (%s:%s)" % (socket.getfqdn(name=self.name), - constants.X_DISPLAY_NUMBER, - ipstr, - constants.X_DISPLAY_NUMBER) - host = self.name - elif ipstr is not None: - self.connxinfo = "%s:%s" % (ipstr, constants.X_DISPLAY_NUMBER) - host = ipstr - else: - self.connxinfo = None - host = "" - - # figure out product info - if host: - self.desktop = _("%(productName)s %(productVersion)s installation " - "on host %(name)s") \ - % {'productName': get_product_name(), - 'productVersion': get_product_version(), - 'name': host} - - def openlogfile(self): - try: - fd = os.open(self.log_file, os.O_RDWR | os.O_CREAT) - except OSError as e: - sys.stderr.write("error opening %s: %s\n", (self.log_file, e)) - fd = None - - return fd - - def connectToView(self): - """Attempt to connect to self.vncconnecthost""" - - maxTries = 10 - self.log.info(_("Attempting to connect to vnc client on host %s..."), self.vncconnecthost) - - if self.vncconnectport != "": - hostarg = self.vncconnecthost + ":" + self.vncconnectport - else: - hostarg = self.vncconnecthost - - vncconfigcommand = [self.root + "/usr/bin/vncconfig", "-display", ":%s" % constants.X_DISPLAY_NUMBER, "-connect", hostarg] - - for _i in range(maxTries): - vncconfp = util.startProgram(vncconfigcommand, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # vncconfig process - err = vncconfp.communicate()[1].decode("utf-8") - - if err == '': - self.log.info(_("Connected!")) - return True - elif err.startswith("connecting") and err.endswith("failed\n"): - self.log.info(_("Will try to connect again in 15 seconds...")) - time.sleep(15) - continue - else: - log.critical(err) - util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) - sys.exit(1) - self.log.error(P_("Giving up attempting to connect after %d try!\n", - "Giving up attempting to connect after %d tries!\n", - maxTries), maxTries) - return False - - def startVncConfig(self): - """Attempt to start vncconfig""" - - self.log.info(_("Attempting to start vncconfig")) - - vncconfigcommand = [self.root + "/usr/bin/vncconfig", "-nowin", "-display", ":%s" % constants.X_DISPLAY_NUMBER] - - # Use startProgram to run vncconfig in the background - util.startProgram(vncconfigcommand, stdout=self.openlogfile(), stderr=subprocess.STDOUT) - - def VNCListen(self): - """Put the server in listening mode. - - We dont really have to do anything for the server to listen :) - """ - if self.connxinfo is not None: - self.log.info(_("Please manually connect your vnc client to %s to begin the install."), self.connxinfo) - else: - self.log.info(_("Please manually connect your vnc client to IP-ADDRESS:%s " - "to begin the install. Switch to the shell (Ctrl-B 2) and " - "run 'ip addr' to find the IP-ADDRESS."), constants.X_DISPLAY_NUMBER) - - def startServer(self): - self.log.info(_("Starting VNC...")) - network.wait_for_connectivity() - - # Lets call it from here for now. - try: - self.initialize() - except (socket.herror, ValueError) as e: - stdoutLog.critical("Could not initialize the VNC server: %s", e) - util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) - sys.exit(1) - - if self.password and (len(self.password) < 6 or len(self.password) > 8): - self.changeVNCPasswdWindow() - - if not self.password: - SecurityTypes = "None" - rfbauth = "0" - else: - SecurityTypes = "VncAuth" - rfbauth = self.pw_file - # Create the password file. - self.setVNCPassword() - - # Lets start the xvnc. - xvnccommand = [XVNC_BINARY_NAME, ":%s" % constants.X_DISPLAY_NUMBER, - "-depth", "24", "-br", - "IdleTimeout=0", "-auth", "/dev/null", "-once", - "DisconnectClients=false", "desktop=%s" % (self.desktop,), - "SecurityTypes=%s" % SecurityTypes, "rfbauth=%s" % rfbauth] - - try: - util.startX(xvnccommand, output_redirect=self.openlogfile(), timeout=self.timeout) - except OSError: - stdoutLog.critical("Could not start the VNC server. Aborting.") - util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) - sys.exit(1) - - self.log.info(_("The VNC server is now running.")) - - # Lets tell the user what we are going to do. - if self.vncconnecthost != "": - self.log.warning(_("\n\nYou chose to connect to a listening vncviewer. \n" - "This does not require a password to be set. If you \n" - "set a password, it will be used in case the connection \n" - "to the vncviewer is unsuccessful\n\n")) - elif self.password == "": - self.log.warning(_("\n\nWARNING!!! VNC server running with NO PASSWORD!\n" - "You can use the inst.vncpassword=PASSWORD boot option\n" - "if you would like to secure the server.\n\n")) - elif self.password != "": - self.log.warning(_("\n\nYou chose to execute vnc with a password. \n\n")) - else: - self.log.warning(_("\n\nUnknown Error. Aborting. \n\n")) - util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) - sys.exit(1) - - # Lets try to configure the vnc server to whatever the user specified - if self.vncconnecthost != "": - connected = self.connectToView() - if not connected: - self.VNCListen() - else: - self.VNCListen() - - # Start vncconfig for copy/paste - self.startVncConfig() - - def changeVNCPasswdWindow(self): - """ Change the password to a sane parameter. - - We ask user to input a password that (len(password) > 6 - and len(password) <= 8) or password == ''. - """ - - message = _("VNC password must be six to eight characters long.\n" - "Please enter a new one, or leave blank for no password.") - App.initialize() - loop = App.get_event_loop() - loop.set_quit_callback(tui_quit_callback) - ui_proxy = RUNTIME.get_proxy(USER_INTERFACE) - vnc_data = VncData.from_structure(ui_proxy.Vnc) - spoke = VNCPassSpoke(self.anaconda.ksdata, None, None, message, vnc_data) - ScreenHandler.schedule_screen(spoke) - App.run() - - vnc_data = VncData.from_structure(ui_proxy.Vnc) - self.password = vnc_data.password diff --git a/tests/unit_tests/pyanaconda_tests/test_simple_import.py b/tests/unit_tests/pyanaconda_tests/test_simple_import.py index 298f7e14846..27b824ead79 100644 --- a/tests/unit_tests/pyanaconda_tests/test_simple_import.py +++ b/tests/unit_tests/pyanaconda_tests/test_simple_import.py @@ -67,7 +67,7 @@ def test_import_pyanaconda(self): "pyanaconda.modules.storage.checker.utils", "pyanaconda.ui.categories", "pyanaconda.ui.gui.spokes.lib.cart", - "pyanaconda.ui.tui.spokes.askvnc", + "pyanaconda.ui.tui.spokes.askrd", "pyanaconda.rescue" ], [ "pyanaconda.modules.storage.partitioning.blivet.blivet_handler", From 8783ed039764bf20402d28a9842c12aa367087be Mon Sep 17 00:00:00 2001 From: Martin Kolman Date: Mon, 27 May 2024 19:04:25 +0200 Subject: [PATCH 14/31] Adjust to freerdp and GNOME package changes Adjust HOME variable to fix credential storage with new freerdp and also adjust GNOME remote desktop options for updated package. Resolves: RHEL-38407 (cherry picked from commit 04d5bb7f5ebeba7fdbd2df67b2f5a57336b10502) --- pyanaconda/core/util.py | 4 ++-- pyanaconda/display.py | 9 ++------- pyanaconda/gnome_remote_destop.py | 4 ++-- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/pyanaconda/core/util.py b/pyanaconda/core/util.py index bfe742257bd..bc2590a759f 100644 --- a/pyanaconda/core/util.py +++ b/pyanaconda/core/util.py @@ -354,7 +354,7 @@ def execWithRedirect(command, argv, stdin=None, stdout=None, root='/', env_prune log_output=log_output, binary_output=binary_output, do_preexec=do_preexec)[0] -def execWithCapture(command, argv, stdin=None, root='/', env_prune=None, +def execWithCapture(command, argv, stdin=None, root='/', env_prune=None, env_add=None, log_output=True, filter_stderr=False, do_preexec=True): """ Run an external program and capture standard out and err. @@ -370,7 +370,7 @@ def execWithCapture(command, argv, stdin=None, root='/', env_prune=None, argv = [command] + argv return _run_program(argv, stdin=stdin, root=root, log_output=log_output, env_prune=env_prune, - filter_stderr=filter_stderr, do_preexec=do_preexec)[1] + env_add=env_add, filter_stderr=filter_stderr, do_preexec=do_preexec)[1] def execWithCaptureAsLiveUser(command, argv, stdin=None, root='/', log_output=True, diff --git a/pyanaconda/display.py b/pyanaconda/display.py index 3ce3fdde9e4..4c8411941c2 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -251,14 +251,9 @@ def wl_preexec(): "--wayland", "--no-x11", "--wayland-display", constants.WAYLAND_SOCKET_NAME]) - # remote access needs gnome-kiosk to start in headless mode & - # configure a virtual monitor + # remote access needs gnome-kiosk to start in headless mode if headless: - # check virtual monitor resolution has been set - if headless_resolution is None: - # use default value - headless_resolution = "1280x1024" - argv.extend(["--headless", "--virtual-monitor", headless_resolution]) + argv.extend(["--headless"]) childproc = util.startProgram(argv, env_add={'XDG_DATA_DIRS': xdg_data_dirs}, preexec_fn=wl_preexec) diff --git a/pyanaconda/gnome_remote_destop.py b/pyanaconda/gnome_remote_destop.py index 3813a1edd6a..1091178611c 100644 --- a/pyanaconda/gnome_remote_destop.py +++ b/pyanaconda/gnome_remote_destop.py @@ -167,7 +167,7 @@ def _run_grdctl(self, argv): # extend the base argv by the caller provided arguments combined_argv = base_argv + argv # make sure HOME is set to /root or else settings might not be saved - execWithCapture("grdctl", combined_argv, env_prune=['HOME']) + execWithCapture("grdctl", combined_argv, env_add={"HOME": "/root"}) def _open_grd_log_file(self): # FIXME: redirect to journal ? @@ -186,7 +186,7 @@ def _start_grd_process(self): global grd_process grd_process = startProgram([GRD_BINARY_PATH, "--headless"], stdout=self._open_grd_log_file(), - env_prune=['HOME']) + env_add={"HOME": "/root"}) self.log.info("GNOME remote desktop is now running.") except OSError: stdoutLog.critical("Could not start GNOME remote desktop. Aborting.") From d033b11f0843c10b8b0d9e7eba3a70937ce26a09 Mon Sep 17 00:00:00 2001 From: Martin Kolman Date: Thu, 20 Jun 2024 18:03:12 +0200 Subject: [PATCH 15/31] Handle inst.rdp in Dracut Looks like we enable network already in dracut if inst.vnc is used, lets switch the code to work with inst.rdp. Also drop the VNC related commands, as they are no longer expected to be used. Resolves: RHEL-41219 (cherry picked from commit d331ba0290cf3c6f8c54201876417d9fd7f3cf4b) --- data/systemd/anaconda-direct.service | 2 +- pyanaconda/argument_parsing.py | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/data/systemd/anaconda-direct.service b/data/systemd/anaconda-direct.service index 2f09e05975f..e05c4408c43 100644 --- a/data/systemd/anaconda-direct.service +++ b/data/systemd/anaconda-direct.service @@ -7,7 +7,7 @@ ConditionPathIsDirectory=|/sys/hypervisor/s390 ConditionKernelCommandLine=|inst.notmux [Service] -Environment=HOME=/root MALLOC_CHECK_=2 MALLOC_PERTURB_=204 PATH=/usr/bin:/bin:/sbin:/usr GDK_BACKEND=x11 XDG_RUNTIME_DIR=/tmp LANG=en_US.UTF-8 +Environment=HOME=/root MALLOC_CHECK_=2 MALLOC_PERTURB_=204 PATH=/usr/bin:/bin:/sbin:/usr GDK_BACKEND=wayland XDG_RUNTIME_DIR=/run/user/0 GIO_USE_VFS=local LANG=en_US.UTF-8 Type=oneshot WorkingDirectory=/root ExecStart=/usr/sbin/anaconda diff --git a/pyanaconda/argument_parsing.py b/pyanaconda/argument_parsing.py index f6429227999..302b2b9f375 100644 --- a/pyanaconda/argument_parsing.py +++ b/pyanaconda/argument_parsing.py @@ -489,15 +489,6 @@ def __call__(self, parser, namespace, values, _option_string=None): # Display ap.add_argument("--resolution", dest="runres", default=None, metavar="WIDTHxHEIGHT", help=help_parser.help_text("resolution")) - ap.add_argument("--usefbx", dest="xdriver", action="store_const", const="fbdev", - help=help_parser.help_text("usefbx")) - ap.add_argument("--vnc", action="store_true", default=False, - help=help_parser.help_text("vnc")) - ap.add_argument("--vncconnect", metavar="HOST:PORT", help=help_parser.help_text("vncconnect")) - ap.add_argument("--vncpassword", default="", metavar="PASSWORD", - help=help_parser.help_text("vncpassword")) - ap.add_argument("--xdriver", dest="xdriver", action="store", type=str, - default=None, metavar="DRIVER", help=help_parser.help_text("xdriver")) ap.add_argument("--xtimeout", dest="xtimeout", action="store", type=int, default=X_TIMEOUT, metavar="TIMEOUT_IN_SECONDS", help=help_parser.help_text("xtimeout")) ap.add_argument("--rdp", action="store_true", default=False, dest="rdp_enabled", From a646178d0e563ab8421fcfeceb18e987907d79b0 Mon Sep 17 00:00:00 2001 From: Martin Kolman Date: Thu, 20 Jun 2024 18:52:45 +0200 Subject: [PATCH 16/31] Cleanup remaining Xorg and VNC references and dead code After the main PR series that turned Anaconda into a native Wayland application and switched from VNC to RDP fro remote access, there were still some leftovers here and there. So lets address those - adjust various references in doc strings to correctly mention Wayland and RDP. And also drop various bits of code that are no longer needed. Lastly, drop a few Anaconda boot options that no longer work in the Wayland world: - xdriver - usefbx - vnc - vncconnect - vncpassword NOTE: Even with Anaconda running natively on a Wayland compositor, keyboard layouts are still called X layouts. This is the correct, as Wayland is still using the keyboard layout format introduced by the X server. Resolves: RHEL-41219 (cherry picked from commit ee142738aa74693098a1fa0e4c178029e5d16b43) --- anaconda.py | 19 ---- anaconda.spec.in | 3 +- data/anaconda_options.txt | 27 +---- data/liveinst/liveinst | 3 - docs/boot-options.rst | 5 + docs/intro.rst | 2 +- dracut/parse-anaconda-options.sh | 4 +- pyanaconda/core/constants.py | 2 +- pyanaconda/core/util.py | 98 +------------------ pyanaconda/display.py | 34 ++----- pyanaconda/exception.py | 2 +- pyanaconda/startup_utils.py | 2 +- pyanaconda/ui/gui/__init__.py | 2 +- pyanaconda/ui/gui/spokes/keyboard.py | 6 +- pyanaconda/ui/tui/spokes/askrd.py | 2 +- tests/pylint/runpylint.py | 4 +- .../pyanaconda_tests/test_argparse.py | 12 +-- widgets/configure.ac | 2 +- 18 files changed, 35 insertions(+), 194 deletions(-) diff --git a/anaconda.py b/anaconda.py index 0a62d9d2e24..1fe55a6ff51 100755 --- a/anaconda.py +++ b/anaconda.py @@ -169,22 +169,6 @@ def setup_environment(): if "EDITOR" not in os.environ and os.path.isfile("/etc/profile.d/nano-default-editor.sh"): os.environ["EDITOR"] = "/usr/bin/nano" - -# pylint: disable=redefined-outer-name -def warn_on_deprecated_options(opts, log): - """Check if deprecated options have been used & log a warning.""" - - if opts.vnc: - log.warning("The vnc option has been deprecated, use the rdp and " - "related options instead.") - if opts.vncconnect: - log.warning("The vncconnect option has been deprecated, use the rdp and " - "related options instead.") - if opts.vncpassword: - log.warning("The vncpassword option has been deprecated, use the rdp and " - "related options instead.") - - if __name__ == "__main__": # check if the CLI help is requested and return it at once, # without importing random stuff and spamming stdout @@ -263,9 +247,6 @@ def warn_on_deprecated_options(opts, log): if opts.updates_url: log.info("Using updates from: %s", opts.updates_url) - # log warning when deprecated options are used - warn_on_deprecated_options(opts, log) - # print errors encountered during boot startup_utils.print_dracut_errors(stdout_log) diff --git a/anaconda.spec.in b/anaconda.spec.in index abfdbaac11a..723c46d458e 100644 --- a/anaconda.spec.in +++ b/anaconda.spec.in @@ -137,7 +137,7 @@ Requires: python3-pid Requires: crypto-policies Requires: crypto-policies-scripts -# required because of the rescue mode and VNC question +# required because of the rescue mode and RDP question Requires: anaconda-tui = %{version}-%{release} # Make sure we get the en locale one way or another @@ -254,7 +254,6 @@ Requires: zram-generator # needed for proper driver disk support - if RPMs must be installed, a repo is needed Requires: createrepo_c # Display stuff moved from lorax templates -Requires: dbus-x11 Requires: gsettings-desktop-schemas Requires: nm-connection-editor Requires: librsvg2 diff --git a/data/anaconda_options.txt b/data/anaconda_options.txt index c3f6e094baa..c1cef37e72c 100644 --- a/data/anaconda_options.txt +++ b/data/anaconda_options.txt @@ -93,33 +93,8 @@ Run in live installation mode. resolution Run GUI installer in the resolution specified, "1024x768" for example. -usefbx -Use the framebuffer X driver instead of attempting to use a hardware-specific one. - -vnc -Enable VNC-based installation. You will need to connect to the machine using a VNC client application. -A VNC install implies that the installed system will boot up in runlevel 3 instead of to the graphical -login screen. The VNC session will be shared. Consider setting a VNC password using the vncpassword -option. This option is not supported for live installations. -This option is deprecated and will be removed in future releases. Use rdp and related options -instead. - -vncconnect -Once installation is up and running, connect to the VNC client named HOST, and optionally use port PORT. -This option is deprecated and will be removed in future releases. - -vncpassword -Enable a password for the VNC connection. This will prevent someone from inadvertently connecting -to the vnc-based installation. Requires the VNC option to be specified as well. If you have specified -vncconnect the PASSWORD will not be used unless connection to host is not possible. Please note that -the password needs to be 6 to 8 characters long (limitation of the VNC protocol).This option is deprecated -and will be removed in future releases. Use rdp and related options instead. - -xdriver -Use DRIVER as the X driver to use during installation as well as on the installed system. - xtimeout -Specify the timeout in seconds for starting X server. +Specify the timeout in seconds for starting X server or Wayland compositor. rdp Enable Remote Desktop Protocol-controlled installation. You will need to connect to the machine using an RDP diff --git a/data/liveinst/liveinst b/data/liveinst/liveinst index eb6caa389b3..2959d407a0c 100755 --- a/data/liveinst/liveinst +++ b/data/liveinst/liveinst @@ -69,9 +69,6 @@ fi # Process cmdline args for opt in $(cat /proc/cmdline) "$@"; do case $opt in - xdriver=*) - ANACONDA="$ANACONDA --$opt" - ;; updates=*) UPDATES="${opt#updates=}" ;; diff --git a/docs/boot-options.rst b/docs/boot-options.rst index 26827979220..dfb04605f32 100644 --- a/docs/boot-options.rst +++ b/docs/boot-options.rst @@ -590,6 +590,8 @@ inst.xdriver Specify the X driver that should be used during installation and on the installed system. +This boot options is deprecated and has no effect. + .. inst.usefbx inst.usefbx @@ -599,6 +601,9 @@ Use the framebuffer X driver (``fbdev``) rather than a hardware-specific driver. Equivalent to ``inst.xdriver=fbdev``. + +This boot options is deprecated and has no effect. + .. inst.xtimeout: inst.xtimeout diff --git a/docs/intro.rst b/docs/intro.rst index be1a2fed4a8..3c62e77d42c 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -16,7 +16,7 @@ Anaconda is a fairly sophisticated installer. It supports installation from local and remote sources such as CDs and DVDs, images stored on a hard drive, NFS, HTTP, and FTP. Installation can be scripted with kickstart to provide a fully unattended installation that can be duplicated on scores of machines. It -can also be run over VNC on headless machines. A variety of advanced storage +can also be run over RDP on headless machines. A variety of advanced storage devices including LVM, RAID, iSCSI, and multipath are supported from the partitioning program. Anaconda provides advanced debugging features such as remote logging, access to the python interactive debugger, and remote saving of diff --git a/dracut/parse-anaconda-options.sh b/dracut/parse-anaconda-options.sh index 7432bd840e8..bf4f42ca40f 100755 --- a/dracut/parse-anaconda-options.sh +++ b/dracut/parse-anaconda-options.sh @@ -106,8 +106,8 @@ if updates=$(getarg inst.updates); then fi fi -# for vnc bring network up in initramfs so that cmdline configuration is used -getargbool 0 inst.vnc && warn "anaconda requiring network for vnc" && set_neednet +# for rdp bring network up in initramfs so that cmdline configuration is used +getargbool 0 inst.rdp && warn "anaconda requiring network for RDP" && set_neednet # re-read the commandline args unset CMDLINE diff --git a/pyanaconda/core/constants.py b/pyanaconda/core/constants.py index 1c676909411..5232e6d87d9 100644 --- a/pyanaconda/core/constants.py +++ b/pyanaconda/core/constants.py @@ -83,7 +83,7 @@ DRACUT_SHUTDOWN_EJECT = "/run/initramfs/usr/lib/dracut/hooks/shutdown/99anaconda-eject.sh" -# VNC questions +# RDP questions USERDP = N_("Use graphical mode via Remote Desktop Protocol") USETEXT = N_("Use text mode") diff --git a/pyanaconda/core/util.py b/pyanaconda/core/util.py index bc2590a759f..283e55a33a3 100644 --- a/pyanaconda/core/util.py +++ b/pyanaconda/core/util.py @@ -38,9 +38,8 @@ from pyanaconda.core.configuration.anaconda import conf from pyanaconda.core.path import make_directories, open_with_perm, join_paths -from pyanaconda.core.process_watchers import WatchProcesses from pyanaconda.core.constants import DRACUT_SHUTDOWN_EJECT, \ - IPMI_ABORTED, X_TIMEOUT, PACKAGES_LIST_FILE + IPMI_ABORTED, PACKAGES_LIST_FILE from pyanaconda.core.live_user import get_live_user from pyanaconda.errors import RemovedModuleError @@ -165,101 +164,6 @@ def preexec(): return partsubp(preexec_fn=preexec) -class X11Status: - """Status of Xorg launch. - - Values of an instance can be modified from the handler functions. - """ - def __init__(self): - self.started = False - self.timed_out = False - - def needs_waiting(self): - return not (self.started or self.timed_out) - - -def startX(argv, output_redirect=None, timeout=X_TIMEOUT): - """ Start X and return once X is ready to accept connections. - - X11, if SIGUSR1 is set to SIG_IGN, will send SIGUSR1 to the parent - process once it is ready to accept client connections. This method - sets that up and waits for the signal or bombs out if nothing happens - for a minute. The process will also be added to the list of watched - processes. - - :param argv: The command line to run, as a list - :param output_redirect: file or file descriptor to redirect stdout and stderr to - :param timeout: Number of seconds to timing out. - """ - x11_status = X11Status() - - # Handle successful start before timeout - def sigusr1_success_handler(num, frame): - log.debug("X server has signalled a successful start.") - x11_status.started = True - - # Fail after, let's say a minute, in case something weird happens - # and we don't receive SIGUSR1 - def sigalrm_handler(num, frame): - # Check that it didn't make it under the wire - if x11_status.started: - return - x11_status.timed_out = True - log.error("Timeout trying to start %s", argv[0]) - - # Handle delayed start after timeout - def sigusr1_too_late_handler(num, frame): - if x11_status.timed_out: - log.debug("SIGUSR1 received after X server timeout. Switching back to tty1. " - "SIGUSR1 now again initiates test of exception reporting.") - signal.signal(signal.SIGUSR1, old_sigusr1_handler) - - # preexec_fn to add the SIGUSR1 handler in the child we are starting - # see man page XServer(1), section "signals" - def sigusr1_preexec(): - signal.signal(signal.SIGUSR1, signal.SIG_IGN) - - old_sigalrm_handler = None - old_sigusr1_handler = None - childproc = None - try: - old_sigusr1_handler = signal.signal(signal.SIGUSR1, sigusr1_success_handler) - old_sigalrm_handler = signal.signal(signal.SIGALRM, sigalrm_handler) - - # Start the timer - log.debug("Setting timeout %s seconds for starting X.", timeout) - signal.alarm(timeout) - - childproc = startProgram(argv, stdout=output_redirect, stderr=output_redirect, - preexec_fn=sigusr1_preexec) - WatchProcesses.watch_process(childproc, argv[0]) - - # Wait for SIGUSR1 or SIGALRM - while x11_status.needs_waiting(): - signal.pause() - - finally: - # Stop the timer - signal.alarm(0) - signal.signal(signal.SIGALRM, old_sigalrm_handler) - - # Handle outcome of X start attempt - if x11_status.started: - signal.signal(signal.SIGUSR1, old_sigusr1_handler) - elif x11_status.timed_out: - signal.signal(signal.SIGUSR1, sigusr1_too_late_handler) - # Kill Xorg because from now on we will not use it. It will exit only after sending - # the signal, but at least we don't have to track that. - WatchProcesses.unwatch_process(childproc) - childproc.terminate() - log.debug("Exception handler test suspended to prevent accidental activation by " - "delayed Xorg start. Next SIGUSR1 will be handled as delayed Xorg start.") - # Raise an exception to notify the caller that things went wrong. This affects - # particularly pyanaconda.display.do_startup_wl_actions(), where the window manager - # is started immediately after this. The WM would just wait forever. - raise TimeoutError("Timeout trying to start %s" % argv[0]) - - def _run_program(argv, root='/', stdin=None, stdout=None, env_prune=None, log_output=True, binary_output=False, filter_stderr=False, do_preexec=True, env_add=None, user=None): diff --git a/pyanaconda/display.py b/pyanaconda/display.py index 4c8411941c2..e5a7cc5b832 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -190,7 +190,7 @@ def check_rd_can_be_started(anaconda): # disable remote desktop question if we don't have network network_proxy = NETWORK.get_proxy() if not network_proxy.IsConnecting() and not network_proxy.Connected: - error_messages.append("Not asking for VNC because we don't have a network") + error_messages.append("Not asking for RDP mode because we don't have a network") rd_startup_possible = False # disable remote desktop question if we don't have GNOME remote desktop @@ -202,13 +202,8 @@ def check_rd_can_be_started(anaconda): def do_startup_wl_actions(timeout, headless=False, headless_resolution=None): - """Start the window manager. + """Start the Wayland compositor. - When window manager actually connects to the X server is unknowable, but - fortunately it doesn't matter. Wm does not need to be the first - connection to Xorg, and if anaconda starts up before wm, wm - will just take over and maximize the window and make everything right, - fingers crossed. Add XDG_DATA_DIRS to the environment to pull in our overridden schema files. @@ -284,19 +279,6 @@ def set_resolution(runres): log.error("The resolution was not set: %s", error) -def write_xdriver(driver, root=None): - """Write the X driver.""" - if root is None: - root = conf.target.system_root - - if not os.path.isdir("%s/etc/X11" % (root,)): - os.makedirs("%s/etc/X11" % (root,), mode=0o755) - - f = open("%s/etc/X11/xorg.conf" % (root,), 'w') - f.write('Section "Device"\n\tIdentifier "Videocard0"\n\tDriver "%s"\nEndSection\n' % driver) - f.close() - - # general display startup def setup_display(anaconda, options): """Setup the display for the installation environment. @@ -334,9 +316,6 @@ def setup_display(anaconda, options): # note if we have both set rdp_credentials_sufficient = options.rdp_username and options.rdp_password - if options.xdriver: - write_xdriver(options.xdriver, root="/") - # check if GUI without WebUI if anaconda.gui_mode and not anaconda.is_webui_supported: mods = (tup[1] for tup in pkgutil.iter_modules(pyanaconda.ui.__path__, "pyanaconda.ui.")) @@ -415,9 +394,10 @@ def on_mutter_ready(observer): if anaconda.tui_mode and anaconda.gui_startup_failed and flags.rd_question: - message = _("X was unable to start on your machine. Would you like to start VNC to connect to " - "this computer from another computer and perform a graphical installation or continue " - "with a text mode installation?") + message = _("Wayland was unable to start on your machine. Would you like to start " + "an RDP session to connect to this computer from another computer and " + "perform a graphical installation or continue with a text mode " + "installation?") ask_rd_question(anaconda, grd_server, message) # if they want us to use RDP do that now @@ -425,7 +405,7 @@ def on_mutter_ready(observer): do_startup_wl_actions(xtimeout, headless=True, headless_resolution=options.runres) grd_server.start_grd_rdp() - # with X running we can initialize the UI interface + # with Wayland running we can initialize the UI interface anaconda.initialize_interface() if anaconda.gui_startup_failed: diff --git a/pyanaconda/exception.py b/pyanaconda/exception.py index 28ee8c4aa56..87a43fc8a3c 100644 --- a/pyanaconda/exception.py +++ b/pyanaconda/exception.py @@ -173,7 +173,7 @@ def handleException(self, dump_info): except (RuntimeError, ImportError, ValueError): log.debug("Gtk cannot be initialized") - # X not running (Gtk cannot be initialized) + # Wayland not running (Gtk cannot be initialized) if thread_manager.in_main_thread(): log.debug("In the main thread, running exception handler") if issubclass(ty, NonInteractiveError) or not self._interactive: diff --git a/pyanaconda/startup_utils.py b/pyanaconda/startup_utils.py index 3b39000c505..4748c1a506e 100644 --- a/pyanaconda/startup_utils.py +++ b/pyanaconda/startup_utils.py @@ -552,7 +552,7 @@ def initialize_default_systemd_target(text_mode): NOTE: - Installation controlled via VNC is considered to be + Installation controlled via RDP is considered to be a text mode installation, as the installation run itself is effectively headless. diff --git a/pyanaconda/ui/gui/__init__.py b/pyanaconda/ui/gui/__init__.py index dfa8e3462d3..a47edd3a1c5 100644 --- a/pyanaconda/ui/gui/__init__.py +++ b/pyanaconda/ui/gui/__init__.py @@ -502,7 +502,7 @@ def reapply_language(self): class GraphicalUserInterface(UserInterface): """This is the standard GTK+ interface we try to steer everything to using. - It is suitable for use both directly and via VNC. + It is suitable for use both directly and via RDP. """ def __init__(self, storage, payload, distributionText=get_distribution_text, diff --git a/pyanaconda/ui/gui/spokes/keyboard.py b/pyanaconda/ui/gui/spokes/keyboard.py index 2f97bf80185..66efb407bf1 100644 --- a/pyanaconda/ui/gui/spokes/keyboard.py +++ b/pyanaconda/ui/gui/spokes/keyboard.py @@ -357,7 +357,7 @@ def completed(self): # Below are checks if we want users attention when the spoke wasn't confirmed (visited) - # Not an issue for VNC, since VNC keymaps are weird and more on the client side. + # Not an issue for RDP, since RDP keymaps are weird and more on the client side. if flags.flags.use_rd: return True @@ -481,7 +481,7 @@ def _addLayout(self, store, name): def _removeLayout(self, store, itr): """ Remove the layout specified by store iterator from the store and - X runtime configuration. + Wayland runtime configuration. """ @@ -491,7 +491,7 @@ def _refresh_switching_info(self): switch_options = self._l12_module.LayoutSwitchOptions if flags.flags.use_rd: self._layoutSwitchLabel.set_text(_("Keyboard layouts are not " - "supported when using VNC.\n" + "supported when using RDP.\n" "However the settings will be used " "after the installation.")) elif switch_options: diff --git a/pyanaconda/ui/tui/spokes/askrd.py b/pyanaconda/ui/tui/spokes/askrd.py index 4c6428b5dba..781817fc7a7 100644 --- a/pyanaconda/ui/tui/spokes/askrd.py +++ b/pyanaconda/ui/tui/spokes/askrd.py @@ -137,7 +137,7 @@ def apply(self): class RDPAuthSpoke(NormalTUISpoke): """ - .. inheritance-diagram:: VNCPassSpoke + .. inheritance-diagram:: RDPAuthSpoke :parts: 3 """ diff --git a/tests/pylint/runpylint.py b/tests/pylint/runpylint.py index 64568bbf6da..5058cdcfd27 100755 --- a/tests/pylint/runpylint.py +++ b/tests/pylint/runpylint.py @@ -103,10 +103,10 @@ def setup_environment(): # Don't try to connect to the accessibility socket. os.environ["NO_AT_BRIDGE"] = "1" - # Force the GDK backend to X11. Otherwise if no display can be found, Gdk + # Force the GDK backend to Wayland. Otherwise if no display can be found, Gdk # tries every backend type, which includes "broadway", which prints an error # and keeps changing the content of said error. - os.environ["GDK_BACKEND"] = "x11" + os.environ["GDK_BACKEND"] = "wayland" # Save analysis data in the pylint directory. os.environ["PYLINTHOME"] = builddir + "/tests/pylint/.pylint.d" diff --git a/tests/unit_tests/pyanaconda_tests/test_argparse.py b/tests/unit_tests/pyanaconda_tests/test_argparse.py index a27613be5d1..9cd2baba59d 100644 --- a/tests/unit_tests/pyanaconda_tests/test_argparse.py +++ b/tests/unit_tests/pyanaconda_tests/test_argparse.py @@ -36,10 +36,10 @@ def test_without_inst_prefix(self): assert opts.stage2 is None boot_cmdline = KernelArguments.from_string("stage2=http://cool.server.com/test " - "vnc") + "rdp") opts = self._parseCmdline([], boot_cmdline=boot_cmdline) assert opts.stage2 is None - assert not opts.vnc + assert not opts.rdp_enabled def test_with_inst_prefix(self): boot_cmdline = KernelArguments.from_string("inst.stage2=http://cool.server.com/test") @@ -47,17 +47,17 @@ def test_with_inst_prefix(self): assert opts.stage2 == "http://cool.server.com/test" boot_cmdline = KernelArguments.from_string("inst.stage2=http://cool.server.com/test " - "inst.vnc") + "inst.rdp") opts = self._parseCmdline([], boot_cmdline=boot_cmdline) assert opts.stage2 == "http://cool.server.com/test" - assert opts.vnc + assert opts.rdp_enabled def test_inst_prefix_mixed(self): boot_cmdline = KernelArguments.from_string("inst.stage2=http://cool.server.com/test " - "vnc") + "rdp") opts = self._parseCmdline([], boot_cmdline=boot_cmdline) assert opts.stage2 == "http://cool.server.com/test" - assert not opts.vnc + assert not opts.rdp_enabled def test_display_mode(self): opts = self._parseCmdline(['--cmdline']) diff --git a/widgets/configure.ac b/widgets/configure.ac index 3f8f0bc1123..c38a3ae6db5 100644 --- a/widgets/configure.ac +++ b/widgets/configure.ac @@ -71,7 +71,7 @@ AS_IF([test "x$enable_glade" != "xno"], AC_CONFIG_FILES([glade/Makefile])], [AC_SUBST(GLADE_SUBDIR, "")]) -ANACONDA_PKG_CHECK_MODULES([GTK], [gtk+-x11-3.0 >= 3.11.3]) +ANACONDA_PKG_CHECK_MODULES([GTK], [gtk+-wayland-3.0 >= 3.11.3]) ANACONDA_PKG_CHECK_EXISTS([gobject-introspection-1.0 >= 1.30]) # Use AM_PATH_GLIB_2_0 to define some extra glib-related variables From 21bc6f96e63245f722df5b0eefcd1afcf2dc4678 Mon Sep 17 00:00:00 2001 From: Martin Kolman Date: Wed, 21 Aug 2024 10:59:05 +0200 Subject: [PATCH 17/31] Redirect Anaconda main process stderr to Journal This should prevent error messages (at the moment mostly from GTK, which stil runs in the main thread) from swamping the TUI running on TTY1. This also has the added benefit of any such errors now being captured in the Journal, which is usually stored from any test runs and easy for customers to send back if they encounter an issue. And thanks a lot to Ray Strode for helping us track this down! :) Resolves: RHEL-47097 (cherry picked from commit 9406a6a89a7df2fe782e755f69dbc6db53c5a0b2) --- anaconda.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/anaconda.py b/anaconda.py index 1fe55a6ff51..d9f66c363ad 100755 --- a/anaconda.py +++ b/anaconda.py @@ -32,6 +32,16 @@ from pyanaconda.modules.common.structures.rescue import RescueData +# Redirect Anaconda main process stderr to Journal, +# as otherwise this could end up writing all over +# the TUI on TTY1. + +# create an appropriately named Journal writing stream +from systemd import journal +anaconda_stderr_stream = journal.stream("anaconda", priority=journal.LOG_ERR) +# redirect stderr of this process to the stream +os.dup2(anaconda_stderr_stream.fileno(), sys.stderr.fileno()) + def exitHandler(rebootData): # Clear the list of watched PIDs. From f3da24a92f83acf84afd343b692b9c3903a20f2a Mon Sep 17 00:00:00 2001 From: Martin Kolman Date: Wed, 21 Aug 2024 11:57:33 +0200 Subject: [PATCH 18/31] Remove leftover debugging message The address is printed correctly a few line below. Resolves: RHEL-47097 (cherry picked from commit fcb5154465cbfff3d60c0e979385c37d33985782) --- pyanaconda/gnome_remote_destop.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyanaconda/gnome_remote_destop.py b/pyanaconda/gnome_remote_destop.py index 1091178611c..68c540124c6 100644 --- a/pyanaconda/gnome_remote_destop.py +++ b/pyanaconda/gnome_remote_destop.py @@ -148,7 +148,6 @@ def _find_network_address(self): try: hinfo = socket.gethostbyaddr(self.ip) - self.log.info(hinfo) if len(hinfo) == 3: # Consider as coming from a valid DNS record only if single IP is returned if len(hinfo[2]) == 1: From 318946db3442e2fc7613449aad94f3dac3a71a5b Mon Sep 17 00:00:00 2001 From: Martin Kolman Date: Tue, 20 Aug 2024 16:38:09 +0200 Subject: [PATCH 19/31] Redirect output of various GNOME related tools to Journal This should avoid the output spamming TUI on TTY1 & any errors will be captured in Journal dumps. Resolves: RHEL-47097 (cherry picked from commit 487a26bcc0648f54d6d10cf81b7b0e566c31476c) --- pyanaconda/display.py | 11 ++++++++++- pyanaconda/gnome_remote_destop.py | 18 +++++++----------- scripts/run-in-new-session | 8 ++++++-- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/pyanaconda/display.py b/pyanaconda/display.py index e5a7cc5b832..1ad43642c12 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -45,6 +45,8 @@ from simpleline import App from simpleline.render.screen_handler import ScreenHandler +from systemd import journal + from pyanaconda.anaconda_loggers import get_module_logger, get_stdout_logger log = get_module_logger(__name__) stdout_log = get_stdout_logger() @@ -250,8 +252,15 @@ def wl_preexec(): if headless: argv.extend(["--headless"]) + # redirect stdout and stderr from GNOME Kiosk to journal + gnome_kiosk_stdout_stream = journal.stream("gnome-kiosk", priority=journal.LOG_INFO) + gnome_kiosk_stderr_stream = journal.stream("gnome-kiosk", priority=journal.LOG_ERR) + childproc = util.startProgram(argv, env_add={'XDG_DATA_DIRS': xdg_data_dirs}, - preexec_fn=wl_preexec) + preexec_fn=wl_preexec, + stdout=gnome_kiosk_stdout_stream, + stderr=gnome_kiosk_stderr_stream, + ) WatchProcesses.watch_process(childproc, argv[0]) for _i in range(0, int(timeout / 0.1)): diff --git a/pyanaconda/gnome_remote_destop.py b/pyanaconda/gnome_remote_destop.py index 68c540124c6..8cff8351397 100644 --- a/pyanaconda/gnome_remote_destop.py +++ b/pyanaconda/gnome_remote_destop.py @@ -21,6 +21,8 @@ import sys import time import socket + +from systemd import journal from pyanaconda import network from pyanaconda.core import util from pyanaconda.core.util import execWithCapture, startProgram @@ -168,23 +170,17 @@ def _run_grdctl(self, argv): # make sure HOME is set to /root or else settings might not be saved execWithCapture("grdctl", combined_argv, env_add={"HOME": "/root"}) - def _open_grd_log_file(self): - # FIXME: redirect to journal ? - try: - fd = os.open(GRD_LOG_FILE, os.O_RDWR | os.O_CREAT) - except OSError as e: - sys.stderr.write("error opening %s: %s\n", (GRD_LOG_FILE, e)) - fd = None - - return fd - def _start_grd_process(self): """Start the GNOME remote desktop process.""" try: self.log.info("Starting GNOME remote desktop.") global grd_process + # forward GRD stdout & stderr to Journal + grd_stdout_stream = journal.stream("gnome-remote-desktop", priority=journal.LOG_INFO) + grd_stderr_stream = journal.stream("gnome-remote-desktop", priority=journal.LOG_ERR) grd_process = startProgram([GRD_BINARY_PATH, "--headless"], - stdout=self._open_grd_log_file(), + stdout=grd_stdout_stream, + stderr=grd_stderr_stream, env_add={"HOME": "/root"}) self.log.info("GNOME remote desktop is now running.") except OSError: diff --git a/scripts/run-in-new-session b/scripts/run-in-new-session index 80aa673eb37..97663060358 100755 --- a/scripts/run-in-new-session +++ b/scripts/run-in-new-session @@ -28,6 +28,7 @@ import signal import struct import subprocess import sys +from systemd import journal VT_GETSTATE = 0x5603 VT_ACTIVATE = 0x5606 @@ -118,9 +119,12 @@ def run_program_in_new_session(arguments, pam_environment, user, service, print(f"Could not wait for VT {vt} to change: {e}", file=old_tty_output) try: + # redirect output (both stodout & stderr) from the command to Journal + new_session_stdout_stream = journal.stream("run-in-new-session", priority=journal.LOG_INFO) + new_session_stderr_stream = journal.stream("run-in-new-session", priority=journal.LOG_ERR) os.dup2(tty_input.fileno(), 0) - os.dup2(tty_output.fileno(), 1) - os.dup2(tty_output.fileno(), 2) + os.dup2(new_session_stdout_stream.fileno(), 1) + os.dup2(new_session_stderr_stream.fileno(), 2) except OSError as e: print(f"Could not set up standard i/o: {e}", file=old_tty_output) From 52262c828368e81d818227d8577cefb99e811b5c Mon Sep 17 00:00:00 2001 From: Jiri Konecny Date: Mon, 26 Aug 2024 17:52:34 +0200 Subject: [PATCH 20/31] Add missing support to localed for compositor The plan is to use systemd-localed to control compositor keyboard and replace current `gk_keyboard_manager`. Most of the code is in place already but we need to add a few missing ones. The most problematic is missing support for next layout. The issue is that localed service don't have support for selection, to resolve this issue we will set the first in the list as selected. However, it means that we have to keep what user has set from Anaconda so we can find next candidate when switch to next layout is requested. Keep the information when user set the layouts to the compositor. --- pyanaconda/modules/localization/localed.py | 104 +++++++- .../localization/test_localed_wrapper.py | 223 ++++++++++++++++++ 2 files changed, 324 insertions(+), 3 deletions(-) diff --git a/pyanaconda/modules/localization/localed.py b/pyanaconda/modules/localization/localed.py index 10b4f95081a..3c25c68c319 100644 --- a/pyanaconda/modules/localization/localed.py +++ b/pyanaconda/modules/localization/localed.py @@ -30,6 +30,7 @@ class LocaledWrapper(object): def __init__(self): self._localed_proxy = None + self._user_layouts_variants = [] if not conf.system.provides_system_bus: log.debug("Not using localed service: " @@ -77,6 +78,15 @@ def layouts_variants(self): return [join_layout_variant(layout, variant) for layout, variant in zip(layouts, variants)] + @property + def current_layout_variant(self): + """Get first (current) layout with variant. + + :return: a list of "layout (variant)" or "layout" layout specifications + :rtype: list(str) + """ + return "" if not self.layouts_variants else self.layouts_variants[0] + @property def options(self): """Get current X11 options. @@ -125,7 +135,7 @@ def convert_keymap(self, keymap): orig_layouts_variants = self.layouts_variants orig_keymap = self.keymap converted_layouts = self.set_and_convert_keymap(keymap) - self.set_layouts(orig_layouts_variants) + self._set_layouts(orig_layouts_variants) self.set_keymap(orig_keymap) return converted_layouts @@ -155,6 +165,12 @@ def set_layouts(self, layouts_variants, options=None, convert=False): (see set_and_convert_layouts) :type convert: bool """ + # store configuration from user + self._set_layouts(layouts_variants, options, convert) + log.debug("Storing layouts for compositor configured by user") + self._user_layouts_variants = layouts_variants + + def _set_layouts(self, layouts_variants, options=None, convert=False): if not self._localed_proxy: return @@ -162,6 +178,9 @@ def set_layouts(self, layouts_variants, options=None, convert=False): variants = [] parsing_failed = False + log.debug("Setting system/compositor keyboard layouts: '%s' options: '%s' convert: '%s", + layouts_variants, options, convert) + for layout_variant in (nonempty for nonempty in layouts_variants if nonempty): try: (layout, variant) = parse_layout_variant(layout_variant) @@ -198,7 +217,7 @@ def set_and_convert_layouts(self, layouts_variants): :rtype: str """ - self.set_layouts(layouts_variants, convert=True) + self._set_layouts(layouts_variants, convert=True) return self.keymap @@ -223,7 +242,86 @@ def convert_layouts(self, layouts_variants): orig_layouts_variants = self.layouts_variants orig_keymap = self.keymap ret = self.set_and_convert_layouts(layouts_variants) - self.set_layouts(orig_layouts_variants) + self._set_layouts(orig_layouts_variants) self.set_keymap(orig_keymap) return ret + + # TODO: rename to select_layout + def set_current_layout(self, layout_variant): + """Set given layout as first (current) layout for compositor. + + This will search for the given layout variant in the list and move it as first in the list. + + :param layout_variant: The layout to set, with format "layout (variant)" + (e.g. "cz (qwerty)") + :type layout_variant: str + :return: If the keyboard layout was activated + :rtype: bool + """ + # ignore compositor layouts but force Anaconda configuration + layouts = self._user_layouts_variants + + try: + new_layouts = self._shift_list(layouts, layout_variant) + self._set_layouts(new_layouts) + return True + except ValueError: + log.warning("Can't set layout: '%s' as first to the current set: %s", + layout_variant, layouts) + return False + + @staticmethod + def _shift_list(source_layouts, value_to_first): + """Helper method to reorder list of layouts and move one as first in the list. + + We should preserve the ordering just shift items from start of the list to the + end in the same order. + + When we want to set 2nd as first in this list: + ["cz", "es", "us"] + The result should be: + ["es", "us", "cz"] + + So the compositor has the same next layout as Anaconda. + + :raises: ValueError: if the list is small or the layout is not inside + """ + value_id = source_layouts.index(value_to_first) + new_list = source_layouts[value_id:len(source_layouts)] + source_layouts[0:value_id] + return new_list + + def select_next_layout(self): + """Select (make it first) next layout for compositor. + + Find current compositor layout in the list of defined layouts and set next to it as + current (first) for compositor. We need to have user defined list because compositor + layouts will change with the selection. Store this list when user is setting configuration + to compositor. This list must not change ordering. + + :param user_layouts: List of layouts selected by user in Anaconda. + :type user_layouts: [str] + :return: If switch was successful True otherwise False + :rtype: bool + """ + current_layout = self.current_layout_variant + layout_id = 0 + + if not self._user_layouts_variants: + log.error("Can't switch next layout - user defined keyboard layout is not present!") + return False + + # find next layout + for i, v in enumerate(self._user_layouts_variants): + if v == current_layout: + layout_id = i + 1 + layout_id %= len(self._user_layouts_variants) + + try: + new_layouts = self._shift_list(self._user_layouts_variants, + self._user_layouts_variants[layout_id]) + self._set_layouts(new_layouts) + return True + except ValueError: + log.warning("Can't set next keyboard layout %s", self._user_layouts_variants) + return False diff --git a/tests/unit_tests/pyanaconda_tests/modules/localization/test_localed_wrapper.py b/tests/unit_tests/pyanaconda_tests/modules/localization/test_localed_wrapper.py index 48b060a5f61..8f8471db173 100644 --- a/tests/unit_tests/pyanaconda_tests/modules/localization/test_localed_wrapper.py +++ b/tests/unit_tests/pyanaconda_tests/modules/localization/test_localed_wrapper.py @@ -66,6 +66,7 @@ def test_localed_wrapper_properties(self, mocked_conf, mocked_localed_service, "cz" assert localed_wrapper.layouts_variants == \ ["cz (qwerty)", "fi", "us (euro)", "fr"] + assert localed_wrapper.current_layout_variant == "cz (qwerty)" assert localed_wrapper.options == \ ["grp:alt_shift_toggle", "grp:ctrl_alt_toggle"] @@ -76,6 +77,7 @@ def test_localed_wrapper_properties(self, mocked_conf, mocked_localed_service, assert localed_wrapper.keymap == "" assert localed_wrapper.options == [] assert localed_wrapper.layouts_variants == [] + assert localed_wrapper.current_layout_variant == "" @patch("pyanaconda.modules.localization.localed.SystemBus") @patch("pyanaconda.modules.localization.localed.LOCALED") @@ -114,6 +116,16 @@ def test_localed_wrapper_safe_calls(self, mocked_conf, mocked_localed_service, localed_wrapper.set_and_convert_layouts(["us-altgr-intl"]) localed_wrapper.convert_layouts(["us-altgr-intl"]) + # verify that user defined list doesn't change + localed_wrapper._user_layouts_variants = [] + localed_wrapper.set_keymap("cz") + localed_wrapper.convert_keymap("cz") + localed_wrapper.set_and_convert_keymap("cz") + assert localed_wrapper._user_layouts_variants == [] + # only set_layouts should change user defined layouts + localed_wrapper.set_layouts(["cz", "us (euro)"]) + assert localed_wrapper._user_layouts_variants == ["cz", "us (euro)"] + @patch("pyanaconda.modules.localization.localed.SystemBus") def test_localed_wrapper_no_systembus(self, mocked_system_bus): """Test LocaledWrapper in environment without system bus. @@ -124,3 +136,214 @@ def test_localed_wrapper_no_systembus(self, mocked_system_bus): mocked_system_bus.check_connection.return_value = False localed_wrapper = LocaledWrapper() self._guarded_localed_wrapper_calls_check(localed_wrapper) + + @patch("pyanaconda.modules.localization.localed.SystemBus") + @patch("pyanaconda.modules.localization.localed.LOCALED") + @patch("pyanaconda.modules.localization.localed.conf") + def test_localed_wrapper_set_current_layout(self, mocked_conf, + mocked_localed_service, + mocked_system_bus): + """Test LocaledWrapper method to set current layout to compositor. + + Verify that the layout to be set is moved to the first place. + """ + mocked_system_bus.check_connection.return_value = True + mocked_conf.system.provides_system_bus = True + mocked_localed_proxy = Mock() + mocked_localed_service.get_proxy.return_value = mocked_localed_proxy + mocked_localed_proxy.X11Layout = "cz,fi,us,fr" + mocked_localed_proxy.X11Variant = "qwerty,,euro" + localed_wrapper = LocaledWrapper() + user_defined = ["cz (qwerty)", "fi", "us (euro)", "fr"] + + # check if layout is correctly set + localed_wrapper._user_layouts_variants = user_defined + localed_wrapper.set_current_layout("fi") + assert user_defined == localed_wrapper._user_layouts_variants # must not change + mocked_localed_proxy.SetX11Keyboard.assert_called_once_with( + "fi,us,fr,cz", + "pc105", # hardcoded + ",euro,,qwerty", + "", + False, + False + ) + + # check if layout is correctly set including variant + mocked_localed_proxy.SetX11Keyboard.reset_mock() + localed_wrapper._user_layouts_variants = user_defined + + assert localed_wrapper.set_current_layout("us (euro)") is True + assert user_defined == localed_wrapper._user_layouts_variants # must not change + mocked_localed_proxy.SetX11Keyboard.assert_called_once_with( + "us,fr,cz,fi", + "pc105", # hardcoded + "euro,,qwerty,", + "", + False, + False + ) + + # check when we are selecting non-existing layout + mocked_localed_proxy.SetX11Keyboard.reset_mock() + mocked_localed_proxy.X11Layout = "fi" + mocked_localed_proxy.X11Variant = "" + localed_wrapper._user_layouts_variants = user_defined + + assert localed_wrapper.set_current_layout("cz") is False + assert user_defined == localed_wrapper._user_layouts_variants # must not change + mocked_localed_proxy.SetX11Keyboard.assert_not_called() + + # check when the layout set is empty + mocked_localed_proxy.SetX11Keyboard.reset_mock() + mocked_localed_proxy.X11Layout = "" + mocked_localed_proxy.X11Variant = "" + localed_wrapper._user_layouts_variants = user_defined + + assert localed_wrapper.set_current_layout("fr") is True + assert user_defined == localed_wrapper._user_layouts_variants # must not change + mocked_localed_proxy.SetX11Keyboard.assert_called_once_with( + "fr,cz,fi,us", + "pc105", # hardcoded + ",qwerty,,euro", + "", + False, + False + ) + + # can't set layout when we don't have user defined set + mocked_localed_proxy.SetX11Keyboard.reset_mock() + mocked_localed_proxy.X11Layout = "cz, us" + mocked_localed_proxy.X11Variant = "" + user_defined = [] + localed_wrapper._user_layouts_variants = user_defined + + assert localed_wrapper.set_current_layout("cz (qwerty)") is False + assert user_defined == localed_wrapper._user_layouts_variants # must not change + mocked_localed_proxy.SetX11Keyboard.assert_not_called() + + @patch("pyanaconda.modules.localization.localed.SystemBus") + @patch("pyanaconda.modules.localization.localed.LOCALED") + @patch("pyanaconda.modules.localization.localed.conf") + def test_localed_wrapper_set_next_layout(self, mocked_conf, + mocked_localed_service, + mocked_system_bus): + """Test LocaledWrapper method to set current layout to compositor. + + Verify that we are selecting next layout to what is currently set in compositor. + Because setting current layout changing the ordering we have to decide next layout based + on the user selection. + """ + mocked_system_bus.check_connection.return_value = True + mocked_conf.system.provides_system_bus = True + mocked_localed_proxy = Mock() + mocked_localed_service.get_proxy.return_value = mocked_localed_proxy + # currently selected is first in this list 'cz (qwerty)' + mocked_localed_proxy.X11Layout = "cz,fi,us,fr" + mocked_localed_proxy.X11Variant = "qwerty,,euro" + localed_wrapper = LocaledWrapper() + + # test switch to next layout + user_defined = ["cz (qwerty)", "fi", "us (euro)", "fr"] + localed_wrapper._user_layouts_variants = user_defined + + assert localed_wrapper.select_next_layout() is True + assert user_defined == localed_wrapper._user_layouts_variants # must not change + mocked_localed_proxy.SetX11Keyboard.assert_called_once_with( + "fi,us,fr,cz", + "pc105", # hardcoded + ",euro,,qwerty", + "", + False, + False + ) + + # test switch to next layout in the middle of user defined list + mocked_localed_proxy.SetX11Keyboard.reset_mock() + user_defined = ["es", "cz (qwerty)", "us (euro)", "fr"] + localed_wrapper._user_layouts_variants = user_defined + + assert localed_wrapper.select_next_layout() is True + assert user_defined == localed_wrapper._user_layouts_variants # must not change + mocked_localed_proxy.SetX11Keyboard.assert_called_once_with( + "us,fr,es,cz", + "pc105", # hardcoded + "euro,,,qwerty", + "", + False, + False + ) + + # test switch to next layout with different user defined list + mocked_localed_proxy.SetX11Keyboard.reset_mock() + user_defined = ["cz (qwerty)", "es"] + localed_wrapper._user_layouts_variants = user_defined + + assert localed_wrapper.select_next_layout() is True + assert user_defined == localed_wrapper._user_layouts_variants # must not change + mocked_localed_proxy.SetX11Keyboard.assert_called_once_with( + "es,cz", + "pc105", # hardcoded + ",qwerty", + "", + False, + False + ) + + # the compositor list is empty test + mocked_localed_proxy.SetX11Keyboard.reset_mock() + mocked_localed_proxy.X11Layout = "" + mocked_localed_proxy.X11Variant = "" + user_defined = ["cz (qwerty)", "es"] + localed_wrapper._user_layouts_variants = user_defined + + assert localed_wrapper.select_next_layout() is True + assert user_defined == localed_wrapper._user_layouts_variants # must not change + mocked_localed_proxy.SetX11Keyboard.assert_called_once_with( + "cz,es", + "pc105", # hardcoded + "qwerty,", + "", + False, + False + ) + + # the user defined list is empty test + mocked_localed_proxy.SetX11Keyboard.reset_mock() + mocked_localed_proxy.X11Layout = "cz,fi,us,fr" + mocked_localed_proxy.X11Variant = "qwerty,,euro" + user_defined = [] + localed_wrapper._user_layouts_variants = user_defined + + assert localed_wrapper.select_next_layout() is False + assert user_defined == localed_wrapper._user_layouts_variants # must not change + mocked_localed_proxy.SetX11Keyboard.assert_not_called() + + # the user defined list has only one value + mocked_localed_proxy.SetX11Keyboard.reset_mock() + mocked_localed_proxy.X11Layout = "cz,fi,us,es" + mocked_localed_proxy.X11Variant = "qwerty,,euro" + user_defined = ["es (euro)"] + localed_wrapper._user_layouts_variants = user_defined + + assert localed_wrapper.select_next_layout() is True + assert user_defined == localed_wrapper._user_layouts_variants # must not change + mocked_localed_proxy.SetX11Keyboard.assert_called_once_with( + "es", + "pc105", # hardcoded + "euro", + "", + False, + False + ) + + # everything is empty + mocked_localed_proxy.SetX11Keyboard.reset_mock() + mocked_localed_proxy.X11Layout = "" + mocked_localed_proxy.X11Variant = "" + user_defined = [] + localed_wrapper._user_layouts_variants = user_defined + + assert localed_wrapper.select_next_layout() is False + assert user_defined == localed_wrapper._user_layouts_variants # must not change + mocked_localed_proxy.SetX11Keyboard.assert_not_called() From 782426519dcb7cae42ed9258187be00ba724c3f8 Mon Sep 17 00:00:00 2001 From: Jiri Konecny Date: Wed, 28 Aug 2024 13:18:13 +0200 Subject: [PATCH 21/31] Add localed signal support to LocaledWrapper with this patch when compositor will change keyboard layout we will be able to react to that in Anaconda. This is mostly useful for Live ISO images. We have two signals currently to resolve that something has changed, one of the signals is that selected layout has changed in the compositor. The issue is that localed service doesn't have information about selected (first is taken as selected). To resolve that we need to keep values from our last query or last signal about the change so we are able to detect the change in selection. --- pyanaconda/modules/localization/localed.py | 50 ++++++ .../localization/test_localed_wrapper.py | 154 ++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/pyanaconda/modules/localization/localed.py b/pyanaconda/modules/localization/localed.py index 3c25c68c319..2f4a43daeac 100644 --- a/pyanaconda/modules/localization/localed.py +++ b/pyanaconda/modules/localization/localed.py @@ -16,6 +16,7 @@ # Red Hat, Inc. # from pyanaconda.core.dbus import SystemBus +from pyanaconda.core.signal import Signal from pyanaconda.modules.common.constants.services import LOCALED from pyanaconda.core.configuration.anaconda import conf from pyanaconda.keyboard import join_layout_variant, parse_layout_variant, \ @@ -31,6 +32,9 @@ class LocaledWrapper(object): def __init__(self): self._localed_proxy = None self._user_layouts_variants = [] + self._last_layouts_variants = [] + self.compositor_layouts_changed = Signal() + self.compositor_selected_layout_changed = Signal() if not conf.system.provides_system_bus: log.debug("Not using localed service: " @@ -43,6 +47,47 @@ def __init__(self): return self._localed_proxy = LOCALED.get_proxy() + self._localed_proxy.PropertiesChanged.connect(self._on_properties_changed) + + def _on_properties_changed(self, interface, changed_props, invalid_props): + if "X11Layout" in changed_props or "X11Variant" in changed_props: + layouts_variants = self._from_localed_format(changed_props["X11Layout"].get_string(), + changed_props["X11Variant"].get_string()) + # This part is a bit tricky. The signal processing here means that compositor has + # changed current layouts configuration. This could happen for multiple reasons: + # - user changed the layout in compositor + # - Anaconda set the layout to compositor + # - any other magic logic for compositor (we just don't know) + # + # The question is how we should behave: + # - we don't want to take compositor layouts to Anaconda because that will change + # what user will have in the installed system. + # - we don't want to force our layouts to compositor because that would forbid user + # to change compositor layout when Anaconda runs in background + # + # The best shot seems to just signal out that the layout has changed and nothing else. + + # layouts has changed in compositor, always emit this signal + log.debug("Localed layouts has changed. Last known: '%s' current: '%s'", + self._last_layouts_variants, layouts_variants) + self.compositor_layouts_changed.emit(layouts_variants) + + # check if last selected variant has changed + # nothing is selected in compositor + if not layouts_variants: + log.warning("Compositor layouts not set.") + self.compositor_selected_layout_changed.emit("") + # we don't know last used layouts + elif not self._last_layouts_variants: + log.debug("Compositor selected layout is different. " + "Missing information about last selected layouts.") + self.compositor_selected_layout_changed.emit(layouts_variants[0]) + # selected (first) has changed + elif layouts_variants[0] != self._last_layouts_variants[0]: + log.debug("Compositor selected layout is different.") + self.compositor_selected_layout_changed.emit(layouts_variants[0]) + + self._last_layouts_variants = layouts_variants @property def keymap(self): @@ -69,6 +114,11 @@ def layouts_variants(self): layouts = self._localed_proxy.X11Layout variants = self._localed_proxy.X11Variant + self._last_layouts_variants = self._from_localed_format(layouts, variants) + return self._last_layouts_variants + + @staticmethod + def _from_localed_format(layouts, variants): layouts = layouts.split(",") if layouts else [] variants = variants.split(",") if variants else [] diff --git a/tests/unit_tests/pyanaconda_tests/modules/localization/test_localed_wrapper.py b/tests/unit_tests/pyanaconda_tests/modules/localization/test_localed_wrapper.py index 8f8471db173..97a77fba370 100644 --- a/tests/unit_tests/pyanaconda_tests/modules/localization/test_localed_wrapper.py +++ b/tests/unit_tests/pyanaconda_tests/modules/localization/test_localed_wrapper.py @@ -17,6 +17,8 @@ # import unittest from unittest.mock import patch, Mock +from pyanaconda.core.signal import Signal +from pyanaconda.core.glib import Variant from pyanaconda.modules.localization.localed import LocaledWrapper @@ -347,3 +349,155 @@ def test_localed_wrapper_set_next_layout(self, mocked_conf, assert localed_wrapper.select_next_layout() is False assert user_defined == localed_wrapper._user_layouts_variants # must not change mocked_localed_proxy.SetX11Keyboard.assert_not_called() + + @patch("pyanaconda.modules.localization.localed.SystemBus") + @patch("pyanaconda.modules.localization.localed.LOCALED") + @patch("pyanaconda.modules.localization.localed.conf") + def test_localed_wrapper_signals(self, mocked_conf, + mocked_localed_service, + mocked_system_bus): + """Test signals from the localed wrapper + + This one could be tricky. The issue is that this class has to store last known values to + be able to recognize changes. + + We need: + last_known_from_compositor - we need to store what was in compositor before it changed + compositor configuration, so we can correct sent a message + that current selection is different + + None of the information above could be found directly from localed service. + """ + mocked_system_bus.check_connection.return_value = True + mocked_conf.system.provides_system_bus = True + mocked_localed_proxy = Mock() + mocked_localed_proxy.PropertiesChanged = Signal() + mocked_localed_service.get_proxy.return_value = mocked_localed_proxy + mocked_layouts_changed = Mock() + mocked_selected_layout_changed = Mock() + localed_wrapper = LocaledWrapper() + localed_wrapper.compositor_layouts_changed = mocked_layouts_changed + localed_wrapper.compositor_selected_layout_changed = mocked_selected_layout_changed + + def _check_localed_wrapper_signals(last_known_state, compositor_state, + expected_selected_signal, expected_layouts_signal): + """Test the localed wrapper signals are correctly emitted. + + :param last_known_state: State of the localed before the change. Used to resolve if + selected layout has changed. + :type last_known_state: [(str,str)] e.g.:[('cz', 'qwerty'), ('us','')...] + :param compositor_state: New state the compositor will get into. + :type compositor_state: {str: str} e.g.: {"X11Layout": "cz", "X11Variant": "qwerty"} + :param expected_selected_signal: Currently selected layout we expect LocaledWrapper + will signal out. If signal shouldn't set None. + :type expected_selected_signal: str + :param expected_layouts_signal: Current configuration of the compositor signaled from + LocaledWrapper. + :type expected_layouts_signal: [str] e.g.: ["cz", "us (euro)"] + """ + mocked_layouts_changed.reset_mock() + mocked_selected_layout_changed.reset_mock() + # set user defined layouts by setting current ones (mock will take this) + mocked_localed_proxy.X11Layout = ",".join(map(lambda x: x[0], last_known_state)) + mocked_localed_proxy.X11Variant = ",".join(map(lambda x: x[1], last_known_state)) + # loading the above values to local last known list + # pylint: disable=pointless-statement + localed_wrapper.layouts_variants + + for k in compositor_state: + compositor_state[k] = Variant('s', compositor_state[k]) + + mocked_localed_proxy.PropertiesChanged.emit(None, compositor_state, None) + # these signals should be called by localed wrapper + if expected_selected_signal is None: + mocked_selected_layout_changed.emit.assert_not_called() + else: + mocked_selected_layout_changed.emit.assert_called_once_with( + expected_selected_signal + ) + if expected_layouts_signal is None: + mocked_layouts_changed.emit.assert_not_called() + else: + mocked_layouts_changed.emit.assert_called_once_with(expected_layouts_signal) + # we shouldn't set values back to localed service + mocked_localed_proxy.SetX11Keyboard.assert_not_called() + + # basic test compositor changing different values + _check_localed_wrapper_signals( + last_known_state=[], + compositor_state={"X11Options": "grp:something"}, + expected_selected_signal=None, + expected_layouts_signal=None + ) + + # basic test with no knowledge of previous state + _check_localed_wrapper_signals( + last_known_state=[], + compositor_state={"X11Layout": "cz", + "X11Variant": "qwerty"}, + expected_selected_signal="cz (qwerty)", + expected_layouts_signal=["cz (qwerty)"] + ) + + # basic test with no knowledge of previous state and multiple values + _check_localed_wrapper_signals( + last_known_state=[], + compositor_state={"X11Layout": "cz,es", + "X11Variant": "qwerty,"}, + expected_selected_signal="cz (qwerty)", + expected_layouts_signal=["cz (qwerty)", "es"] + ) + + # test no values from compositor + _check_localed_wrapper_signals( + last_known_state=[("cz", "")], + compositor_state={"X11Layout": "", + "X11Variant": ""}, + expected_selected_signal="", + expected_layouts_signal=[] + ) + + # test with knowledge of previous state everything changed + _check_localed_wrapper_signals( + last_known_state=[("es", "euro"), ("us", "")], + compositor_state={"X11Layout": "cz", + "X11Variant": "qwerty"}, + expected_selected_signal="cz (qwerty)", + expected_layouts_signal=["cz (qwerty)"] + ) + + # test with knowledge of previous state no change + _check_localed_wrapper_signals( + last_known_state=[("cz", "qwerty"), ("es", "")], + compositor_state={"X11Layout": "cz,es", + "X11Variant": "qwerty,"}, + expected_selected_signal=None, + expected_layouts_signal=["cz (qwerty)", "es"] + ) + + # test with knowledge of previous state selected has changed + _check_localed_wrapper_signals( + last_known_state=[("cz", "qwerty"), ("es", "")], + compositor_state={"X11Layout": "es,cz", + "X11Variant": ",qwerty"}, + expected_selected_signal="es", + expected_layouts_signal=["es", "cz (qwerty)"] + ) + + # test with knowledge of previous state layouts has changed + _check_localed_wrapper_signals( + last_known_state=[("cz", "qwerty"), ("es", "")], + compositor_state={"X11Layout": "cz,es,us", + "X11Variant": "qwerty,,"}, + expected_selected_signal=None, + expected_layouts_signal=["cz (qwerty)", "es", "us"] + ) + + # test with knowledge of previous state just variant change + _check_localed_wrapper_signals( + last_known_state=[("cz", "qwerty"), ("es", "")], + compositor_state={"X11Layout": "cz,es,us", + "X11Variant": ",,"}, + expected_selected_signal="cz", + expected_layouts_signal=["cz", "es", "us"] + ) From dd9ae5df1ae617e6fedc6aa31f54483c8ca2d8e9 Mon Sep 17 00:00:00 2001 From: Jiri Konecny Date: Wed, 28 Aug 2024 17:24:24 +0200 Subject: [PATCH 22/31] Switch keyboard management to Localed Because of the switch to Wayland Anaconda has to change compositor keyboard manager because libxklavier doesn't work on Wayland. To fix that we migrated to Gnome Kiosk DBus API in RHEL-10, however, this solution can't be used outside of Gnome Kiosk. For that reason, we are switching to Localed which we set as the default to enable Anaconda to control keyboard switching. --- .../modules/common/constants/services.py | 5 - .../localization/gk_keyboard_manager.py | 135 --------------- .../modules/localization/localization.py | 32 ++-- .../localization/gk_keyboard_manager_test.py | 156 ------------------ .../localization/test_module_localization.py | 40 ++--- 5 files changed, 33 insertions(+), 335 deletions(-) delete mode 100644 pyanaconda/modules/localization/gk_keyboard_manager.py delete mode 100644 tests/unit_tests/pyanaconda_tests/modules/localization/gk_keyboard_manager_test.py diff --git a/pyanaconda/modules/common/constants/services.py b/pyanaconda/modules/common/constants/services.py index 88b3e6f41d2..72ed9b4890a 100644 --- a/pyanaconda/modules/common/constants/services.py +++ b/pyanaconda/modules/common/constants/services.py @@ -110,11 +110,6 @@ # Session services. -GK_INPUT_SOURCES = DBusServiceIdentifier( - namespace=("org", "gnome", "Kiosk"), - message_bus=SessionBus -) - MUTTER_DISPLAY_CONFIG = DBusServiceIdentifier( namespace=("org", "gnome", "Mutter", "DisplayConfig"), message_bus=SessionBus diff --git a/pyanaconda/modules/localization/gk_keyboard_manager.py b/pyanaconda/modules/localization/gk_keyboard_manager.py deleted file mode 100644 index 8a68e59990f..00000000000 --- a/pyanaconda/modules/localization/gk_keyboard_manager.py +++ /dev/null @@ -1,135 +0,0 @@ -# -# Copyright (C) 2024 Red Hat, Inc. -# -# This copyrighted material is made available to anyone wishing to use, -# modify, copy, or redistribute it subject to the terms and conditions of -# the GNU General Public License v.2, or (at your option) any later version. -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY expressed or implied, including the implied warranties 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 this program; if not, write to the -# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA -# 02110-1301, USA. Any Red Hat trademarks that are incorporated in the -# source code or documentation are not subject to the GNU General Public -# License and may only be used or replicated with the express permission of -# Red Hat, Inc. -# - -from pyanaconda.core.signal import Signal -from pyanaconda.keyboard import join_layout_variant, parse_layout_variant, KeyboardConfigError -from pyanaconda.modules.common.constants.services import GK_INPUT_SOURCES - - -class GkKeyboardManager(object): - """Class wrapping GNOME Kiosk's input sources API.""" - - def __init__(self): - self.compositor_selected_layout_changed = Signal() - self.compositor_layouts_changed = Signal() - - object_path = GK_INPUT_SOURCES.object_path + '/InputSources/Manager' - self._proxy = GK_INPUT_SOURCES.get_proxy(object_path=object_path) - self._proxy.PropertiesChanged.connect(self._on_properties_changed) - - def _on_properties_changed(self, interface, changed_props, invalid_props): - for prop in changed_props: - if prop == 'SelectedInputSource': - layout_path = changed_props[prop] - layout_variant = self._path_to_layout(layout_path.get_string()) - self.compositor_selected_layout_changed.emit(layout_variant) - if prop == 'InputSources': - layout_paths = changed_props[prop] - layout_variants = map(self._path_to_layout, list(layout_paths)) - self.compositor_layouts_changed.emit(list(layout_variants)) - - def _path_to_layout(self, layout_path): - """Transforms a layout path as returned by GNOME Kiosk to "layout (variant)". - - :param layout_path: D-Bus path to the layout. - (e.g. "/org/gnome/Kiosk/InputSources/xkb_cz_2b_mon_5f_todo_5f_galik") - :type layout_path: str - :return: The layout with format "layout (variant)" (e.g. "cn (mon_todo_galik)") - :rtype: str - - :raise KeyboardConfigError: if layouts with invalid backend type is found - """ - layout_proxy = GK_INPUT_SOURCES.get_proxy(object_path=layout_path) - - if layout_proxy.BackendType != 'xkb': - raise KeyboardConfigError('Failed to get configuration from compositor') - - if '+' in layout_proxy.BackendId: - layout, variant = layout_proxy.BackendId.split('+') - return join_layout_variant(layout, variant) - else: - return layout_proxy.BackendId - - def _layout_to_xkb(self, layout_variant): - """Transforms a "layout (variant)" to a "('xkb', 'layout+variant')". - - :param layout_variant: The layout with format "layout (variant)" (e.g. "cz (qwerty)") - :type layout_variant: str - :return: The layout with format "('xkb', 'layout+variant')" (e.g. "('xkb', 'cz+qwerty')") - :rtype: str - """ - layout, variant = parse_layout_variant(layout_variant) - if variant: - return ('xkb', '{0}+{1}'.format(layout, variant)) - else: - return ('xkb', layout) - - def get_compositor_selected_layout(self): - """Get the activated keyboard layout. - - :return: Current keyboard layout (e.g. "cz (qwerty)") - :rtype: str - """ - layout_path = self._proxy.SelectedInputSource - if not layout_path or layout_path == '/': - return '' - - return self._path_to_layout(layout_path) - - def set_compositor_selected_layout(self, layout_variant): - """Set the activated keyboard layout. - - :param layout_variant: The layout to set, with format "layout (variant)" - (e.g. "cz (qwerty)") - :type layout_variant: str - :return: If the keyboard layout was activated - :rtype: bool - """ - layout_paths = self._proxy.InputSources - for layout_path in layout_paths: - if self._path_to_layout(layout_path) == layout_variant: - self._proxy.SelectInputSource(layout_path) - return True - - return False - - def select_next_compositor_layout(self): - """Set the next available layout as active.""" - self._proxy.SelectNextInputSource() - - def get_compositor_layouts(self): - """Get all available keyboard layouts. - - :return: A list of keyboard layouts (e.g. ["cz (qwerty)", cn (mon_todo_galik)]) - :rtype: list of strings - """ - layout_paths = self._proxy.InputSources - layout_variants = map(self._path_to_layout, list(layout_paths)) - return list(layout_variants) - - def set_compositor_layouts(self, layout_variants, options): - """Set the available keyboard layouts. - - :param layout_variants: A list of keyboard layouts (e.g. ["cz (qwerty)", - cn (mon_todo_galik)]) - :type layout_variants: list of strings - :param options: A list of switching options - :type options: list of strings - """ - xkb_layouts = list(map(self._layout_to_xkb, layout_variants)) - self._proxy.SetInputSources(xkb_layouts, options) diff --git a/pyanaconda/modules/localization/localization.py b/pyanaconda/modules/localization/localization.py index 452481e56ac..6d8e44951d1 100644 --- a/pyanaconda/modules/localization/localization.py +++ b/pyanaconda/modules/localization/localization.py @@ -35,7 +35,6 @@ from pyanaconda.modules.localization.runtime import GetMissingKeyboardConfigurationTask, \ ApplyKeyboardTask, AssignGenericKeyboardSettingTask from pyanaconda.modules.localization.localed import LocaledWrapper -from pyanaconda.modules.localization.gk_keyboard_manager import GkKeyboardManager from pyanaconda.anaconda_loggers import get_module_logger log = get_module_logger(__name__) @@ -71,7 +70,6 @@ def __init__(self): self.compositor_layouts_changed = Signal() self._localed_wrapper = None - self._compositor_keyboard_manager = None def publish(self): """Publish the module.""" @@ -249,6 +247,13 @@ def set_keyboard_seen(self, keyboard_seen): def localed_wrapper(self): if not self._localed_wrapper: self._localed_wrapper = LocaledWrapper() + + self._localed_wrapper.compositor_selected_layout_changed.connect( + self.compositor_selected_layout_changed.emit + ) + self._localed_wrapper.compositor_layouts_changed.connect( + self.compositor_layouts_changed.emit + ) return self._localed_wrapper def install_with_tasks(self): @@ -321,30 +326,17 @@ def set_from_generic_keyboard_setting(self, keyboard): result = task.run() self._update_settings_from_task(result) - @property - def compositor_keyboard_manager(self): - if not self._compositor_keyboard_manager: - self._compositor_keyboard_manager = GkKeyboardManager() - self._compositor_keyboard_manager.compositor_selected_layout_changed.connect( - lambda layout: self.compositor_selected_layout_changed.emit(layout) - ) - self._compositor_keyboard_manager.compositor_layouts_changed.connect( - lambda layouts: self.compositor_layouts_changed.emit(layouts) - ) - - return self._compositor_keyboard_manager - def get_compositor_selected_layout(self): - return self.compositor_keyboard_manager.get_compositor_selected_layout() + return self.localed_wrapper.current_layout_variant def set_compositor_selected_layout(self, layout_variant): - return self.compositor_keyboard_manager.set_compositor_selected_layout(layout_variant) + return self.localed_wrapper.set_current_layout(layout_variant) def select_next_compositor_layout(self): - return self.compositor_keyboard_manager.select_next_compositor_layout() + return self.localed_wrapper.select_next_layout() def get_compositor_layouts(self): - return self.compositor_keyboard_manager.get_compositor_layouts() + return self.localed_wrapper.layouts_variants def set_compositor_layouts(self, layout_variants, options): - self.compositor_keyboard_manager.set_compositor_layouts(layout_variants, options) + self.localed_wrapper.set_layouts(layout_variants, options) diff --git a/tests/unit_tests/pyanaconda_tests/modules/localization/gk_keyboard_manager_test.py b/tests/unit_tests/pyanaconda_tests/modules/localization/gk_keyboard_manager_test.py deleted file mode 100644 index 021b08ab9b9..00000000000 --- a/tests/unit_tests/pyanaconda_tests/modules/localization/gk_keyboard_manager_test.py +++ /dev/null @@ -1,156 +0,0 @@ -# -# Copyright (C) 2024 Red Hat, Inc. -# -# This copyrighted material is made available to anyone wishing to use, -# modify, copy, or redistribute it subject to the terms and conditions of -# the GNU General Public License v.2, or (at your option) any later version. -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY expressed or implied, including the implied warranties 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 this program; if not, write to the -# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA -# 02110-1301, USA. Any Red Hat trademarks that are incorporated in the -# source code or documentation are not subject to the GNU General Public -# License and may only be used or replicated with the express permission of -# Red Hat, Inc. -# -import unittest -import pytest - -from unittest.mock import patch, Mock - -from pyanaconda.modules.localization.gk_keyboard_manager import GkKeyboardManager -from pyanaconda.keyboard import KeyboardConfigError - - -LAYOUT_PROXY_MOCKS = { - "/org/gnome/Kiosk/InputSources/xkb_fr": - Mock(BackendType="xkb", BackendId="fr"), - "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik": - Mock(BackendType="xkb", BackendId="cn+mon_todo_galik"), - "/org/gnome/Kiosk/InputSources/non-xkb_fr": - Mock(BackendType="non-xkb", BackendId="fr"), - "/org/gnome/Kiosk/InputSources/Manager": - Mock(), -} - -MockedGKIS = Mock() -MockedGKIS.get_proxy = lambda object_path: LAYOUT_PROXY_MOCKS[object_path] -MockedGKIS.object_path = "/org/gnome/Kiosk" - - -@patch("pyanaconda.modules.localization.gk_keyboard_manager.GK_INPUT_SOURCES", new=MockedGKIS) -class GkKeyboardManagerTestCase(unittest.TestCase): - """Test the Gnome Kiosk keyboard manager.""" - - def test_properties_changed(self): - """Test _on_properties_changed callback""" - mocked_manager = GkKeyboardManager() - mocked_manager._proxy.InputSources = [ - "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik", - "/org/gnome/Kiosk/InputSources/xkb_fr" - ] - callback1_mock = Mock() - callback2_mock = Mock() - mocked_manager.compositor_selected_layout_changed.connect(callback1_mock) - mocked_manager.compositor_layouts_changed.connect(callback2_mock) - - object_path_mock = Mock() - object_path_mock.get_string.return_value = "/org/gnome/Kiosk/InputSources/xkb_fr" - mocked_manager._on_properties_changed( - "org.gnome.Kiosk.InputSources", - {"SelectedInputSource": object_path_mock}, - {}, - ) - callback1_mock.assert_called_once_with("fr") - callback2_mock.assert_not_called() - - mocked_manager._on_properties_changed( - "org.gnome.Kiosk.InputSources", - {"InputSources": ["/org/gnome/Kiosk/InputSources/xkb_fr"]}, - [], - ) - callback1_mock.assert_called_once_with("fr") - callback2_mock.assert_called_once_with(["fr"]) - - def test_get_compositor_selected_layout(self): - """Test the get_compositor_selected_layout method""" - mocked_manager = GkKeyboardManager() - mocked_manager._proxy.InputSources = [ - "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik", - "/org/gnome/Kiosk/InputSources/xkb_fr" - ] - - mocked_manager._proxy.SelectedInputSource = "/" - assert mocked_manager.get_compositor_selected_layout() == "" - - mocked_manager._proxy.SelectedInputSource = None - assert mocked_manager.get_compositor_selected_layout() == "" - - layout_path = "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik" - mocked_manager._proxy.SelectedInputSource = layout_path - assert mocked_manager.get_compositor_selected_layout() == "cn (mon_todo_galik)" - - def test_set_compositor_selected_layout(self): - """Test the set_compositor_selected_layout method""" - - mocked_manager = GkKeyboardManager() - mocked_manager._proxy.InputSources = [ - "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik", - "/org/gnome/Kiosk/InputSources/xkb_fr" - ] - assert mocked_manager.set_compositor_selected_layout("cn (mon_todo_galik)") is True - mocked_manager._proxy.SelectInputSource.assert_called_with( - "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik" - ) - - # non-xkb type raises exception - # (even in case there is xkb-type data for the layout) - mocked_manager._proxy.InputSources = [ - "/org/gnome/Kiosk/InputSources/non-xkb_fr", - "/org/gnome/Kiosk/InputSources/xkb_fr" - ] - with pytest.raises(KeyboardConfigError): - mocked_manager.set_compositor_selected_layout("fr") - - # Source not found - mocked_manager._proxy.InputSources = [ - "/org/gnome/Kiosk/InputSources/xkb_fr" - ] - assert mocked_manager.set_compositor_selected_layout("cn (mon_todo_galik)") is False - - def test_select_next_compositor_layout(self): - """Test the select_next_compositor_layout method""" - mocked_manager = GkKeyboardManager() - mocked_manager.select_next_compositor_layout() - mocked_manager._proxy.SelectNextInputSource.assert_called_once() - - def test_get_compositor_layouts(self): - """Test the get_compositor_layouts method""" - - mocked_manager = GkKeyboardManager() - mocked_manager._proxy.InputSources = [ - "/org/gnome/Kiosk/InputSources/xkb_cn_2b_mon_5f_todo_5f_galik", - "/org/gnome/Kiosk/InputSources/xkb_fr", - ] - assert mocked_manager.get_compositor_layouts() == ["cn (mon_todo_galik)", "fr"] - - mocked_manager._proxy.InputSources = [ - "/org/gnome/Kiosk/InputSources/non-xkb_fr", - "/org/gnome/Kiosk/InputSources/xkb_fr", - ] - with pytest.raises(KeyboardConfigError): - mocked_manager.get_compositor_layouts() - - def test_set_compositor_layouts(self): - """Test the set_compositor_layouts method""" - mocked_manager = GkKeyboardManager() - mocked_manager.set_compositor_layouts( - ["cz (qwerty)", "fi", "us (euro)", "fr"], - ["grp:alt_shift_toggle", "grp:ctrl_alt_toggle"], - ) - mocked_manager._proxy.SetInputSources.assert_called_with( - [("xkb", "cz+qwerty"), ("xkb", "fi"), ("xkb", "us+euro"), ("xkb", "fr")], - ["grp:alt_shift_toggle", "grp:ctrl_alt_toggle"], - ) diff --git a/tests/unit_tests/pyanaconda_tests/modules/localization/test_module_localization.py b/tests/unit_tests/pyanaconda_tests/modules/localization/test_module_localization.py index 0ed2b6d2b3d..75c4d05a8aa 100644 --- a/tests/unit_tests/pyanaconda_tests/modules/localization/test_module_localization.py +++ b/tests/unit_tests/pyanaconda_tests/modules/localization/test_module_localization.py @@ -393,32 +393,34 @@ def test_keyboard_kickstart4(self): """ self._test_kickstart(ks_in, ks_out) - @patch("pyanaconda.modules.localization.localization.GkKeyboardManager") - def test_compositor_layouts_api(self, gk_manager_cls): - manager_class_mock = Mock() - manager_class_mock.compositor_selected_layout_changed = Signal() - manager_class_mock.compositor_layouts_changed = Signal() - gk_manager_cls.return_value = manager_class_mock + @patch("pyanaconda.modules.localization.localization.LocaledWrapper") + def test_compositor_layouts_api(self, mocked_localed_wrapper): + localed_class_mock = Mock() + localed_class_mock.compositor_selected_layout_changed = Signal() + localed_class_mock.compositor_layouts_changed = Signal() + mocked_localed_wrapper.return_value = localed_class_mock - self.localization_module._compositor_keyboard_manager = None - manager_mock = self.localization_module.compositor_keyboard_manager + self.localization_module._localed_wrapper = None + manager_mock = self.localization_module.localed_wrapper + + manager_mock.current_layout_variant = "cz" + assert self.localization_interface.GetCompositorSelectedLayout() == "cz" - self.localization_interface.GetCompositorSelectedLayout() - # pylint: disable=no-member - manager_mock.get_compositor_selected_layout.assert_called_once() self.localization_interface.SetCompositorSelectedLayout("cz (qwerty)") # pylint: disable=no-member - manager_mock.set_compositor_selected_layout.assert_called_once_with("cz (qwerty)") + manager_mock.set_current_layout.assert_called_once_with("cz (qwerty)") + self.localization_interface.SelectNextCompositorLayout() # pylint: disable=no-member - manager_mock.select_next_compositor_layout.assert_called_once() - self.localization_interface.GetCompositorLayouts() - # pylint: disable=no-member - manager_mock.get_compositor_layouts.assert_called_once() + manager_mock.select_next_layout.assert_called_once() + + manager_mock.layouts_variants = ["us", "es"] + assert self.localization_interface.GetCompositorLayouts() == ["us", "es"] + self.localization_interface.SetCompositorLayouts(["cz (qwerty)", "cn (mon_todo_galik)"], ["option"]) # pylint: disable=no-member - manager_mock.set_compositor_layouts.assert_called_once_with( + manager_mock.set_layouts.assert_called_once_with( ["cz (qwerty)", "cn (mon_todo_galik)"], ["option"] ) @@ -427,13 +429,13 @@ def test_compositor_layouts_api(self, gk_manager_cls): callback_mock = Mock() # pylint: disable=no-member self.localization_interface.CompositorSelectedLayoutChanged.connect(callback_mock) - manager_class_mock.compositor_selected_layout_changed.emit("cz (qwerty)") + localed_class_mock.compositor_selected_layout_changed.emit("cz (qwerty)") callback_mock.assert_called_once_with("cz (qwerty)") callback_mock = Mock() # pylint: disable=no-member self.localization_interface.CompositorLayoutsChanged.connect(callback_mock) - manager_class_mock.compositor_layouts_changed.emit(["cz (qwerty)", "cn (mon_todo_galik)"]) + localed_class_mock.compositor_layouts_changed.emit(["cz (qwerty)", "cn (mon_todo_galik)"]) callback_mock.assert_called_once_with(["cz (qwerty)", "cn (mon_todo_galik)"]) class LocalizationModuleTestCase(unittest.TestCase): From 49e3916de8bd14784c0b59e10ea564088db7961c Mon Sep 17 00:00:00 2001 From: Jiri Konecny Date: Mon, 2 Sep 2024 16:00:22 +0200 Subject: [PATCH 23/31] Remove dead spice_vd_agent code This code is not used since migration to Wayland. We need another solution for this feature. --- pyanaconda/display.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/pyanaconda/display.py b/pyanaconda/display.py index 1ad43642c12..650a68a8d33 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -84,25 +84,6 @@ def start_user_systemd(): os.environ["DBUS_SESSION_BUS_ADDRESS"] = session_bus_address log.info("The session bus address is set to %s.", session_bus_address) -# Spice - -def start_spice_vd_agent(): - """Start the spice vdagent. - - For certain features to work spice requires that the guest os - is running the spice vdagent. - """ - try: - status = util.execWithRedirect("spice-vdagent", []) - except OSError as e: - log.warning("spice-vdagent failed: %s", e) - return - - if status: - log.info("spice-vdagent exited with status %d", status) - else: - log.info("Started spice-vdagent.") - # RDP From efda6c737c0707231894d73fe458d707255ded86 Mon Sep 17 00:00:00 2001 From: Jiri Konecny Date: Tue, 3 Sep 2024 10:31:58 +0200 Subject: [PATCH 24/31] Do not create GRDServer on Live ISO The GRDServer class have checks in the __init__ method which starts to complain about missing GRD in the system. That doesn't apply in Live environment where we don't support GRD in the first place. --- pyanaconda/core/configuration/system.py | 5 ++ pyanaconda/display.py | 21 +++--- pyanaconda/startup_utils.py | 17 +++++ .../core/test_startup_utils.py | 66 ++++++++++++++++++- 4 files changed, 97 insertions(+), 12 deletions(-) diff --git a/pyanaconda/core/configuration/system.py b/pyanaconda/core/configuration/system.py index caedd1e5fa3..b1205ce8d6d 100644 --- a/pyanaconda/core/configuration/system.py +++ b/pyanaconda/core/configuration/system.py @@ -73,6 +73,11 @@ def can_start_user_systemd(self): """Can we start the user instance of systemd?""" return self._is_boot_iso + @property + def can_start_compositor(self): + """Can we start our own Wayland session?""" + return self._is_boot_iso + @property def can_switch_tty(self): """Can we change the foreground virtual terminal?""" diff --git a/pyanaconda/display.py b/pyanaconda/display.py index 650a68a8d33..abcf926192b 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -22,7 +22,6 @@ import os import time import textwrap -import pkgutil import signal from pyanaconda.mutter_display import MutterDisplay, MutterConfigError @@ -37,8 +36,6 @@ from pyanaconda.modules.common.constants.services import NETWORK from pyanaconda.ui.tui.spokes.askrd import AskRDSpoke, RDPAuthSpoke from pyanaconda.ui.tui import tui_quit_callback -# needed for checking if the pyanaconda.ui.gui modules are available -import pyanaconda.ui import blivet @@ -279,6 +276,8 @@ def setup_display(anaconda, options): anaconda.display_mode = options.display_mode anaconda.interactive_mode = not options.noninteractive + # TODO: Refactor this method or maybe whole class, ideally this class should be usable only + # on boot.iso where compositor could be set if flags.rescue_mode: return @@ -287,6 +286,14 @@ def setup_display(anaconda, options): anaconda.initialize_interface() return + # we can't start compositor so not even RDP is supported, do only base initialization + if not conf.system.can_start_compositor: + anaconda.log_display_mode() + anaconda.initialize_interface() + startup_utils.fallback_to_tui_if_gtk_ui_is_not_available(anaconda) + startup_utils.check_memory(anaconda, options) + return + try: xtimeout = int(options.xtimeout) except ValueError: @@ -307,13 +314,7 @@ def setup_display(anaconda, options): rdp_credentials_sufficient = options.rdp_username and options.rdp_password # check if GUI without WebUI - if anaconda.gui_mode and not anaconda.is_webui_supported: - mods = (tup[1] for tup in pkgutil.iter_modules(pyanaconda.ui.__path__, "pyanaconda.ui.")) - if "pyanaconda.ui.gui" not in mods: - stdout_log.warning("Graphical user interface not available, falling back to text mode") - anaconda.display_mode = constants.DisplayModes.TUI - flags.use_rd = False - flags.rd_question = False + startup_utils.fallback_to_tui_if_gtk_ui_is_not_available(anaconda) # check if remote desktop mode can be started rd_can_be_started, rd_error_messages = check_rd_can_be_started(anaconda) diff --git a/pyanaconda/startup_utils.py b/pyanaconda/startup_utils.py index 4748c1a506e..c7ceaa3a874 100644 --- a/pyanaconda/startup_utils.py +++ b/pyanaconda/startup_utils.py @@ -20,6 +20,7 @@ import sys import time import os +import pkgutil from blivet.arch import is_s390 from blivet.util import total_memory from dasbus.typing import get_variant, Int @@ -175,6 +176,22 @@ def set_storage_checker_minimal_ram_size(display_mode): ) +def fallback_to_tui_if_gtk_ui_is_not_available(anaconda): + """Check if GTK UI is available in this environment and fallback to TUI if not. + + Also take into account Web UI. + """ + if anaconda.gui_mode and not anaconda.is_webui_supported: + import pyanaconda.ui + + mods = (tup[1] for tup in pkgutil.iter_modules(pyanaconda.ui.__path__, "pyanaconda.ui.")) + if "pyanaconda.ui.gui" not in mods: + stdout_log.warning("Graphical user interface not available, falling back to text mode") + anaconda.display_mode = DisplayModes.TUI + flags.use_rd = False + flags.rd_question = False + + def setup_logging_from_options(options): """Configure logging according to Anaconda command line/boot options. diff --git a/tests/unit_tests/pyanaconda_tests/core/test_startup_utils.py b/tests/unit_tests/pyanaconda_tests/core/test_startup_utils.py index c027e245654..2c2156f221c 100644 --- a/tests/unit_tests/pyanaconda_tests/core/test_startup_utils.py +++ b/tests/unit_tests/pyanaconda_tests/core/test_startup_utils.py @@ -26,8 +26,10 @@ from textwrap import dedent from pyanaconda.startup_utils import print_dracut_errors, check_if_geolocation_should_be_used, \ - start_geolocation_conditionally, wait_for_geolocation_and_use, apply_geolocation_result -from pyanaconda.core.constants import GEOLOC_CONNECTION_TIMEOUT, TIMEZONE_PRIORITY_GEOLOCATION + start_geolocation_conditionally, wait_for_geolocation_and_use, apply_geolocation_result, \ + fallback_to_tui_if_gtk_ui_is_not_available +from pyanaconda.core.constants import GEOLOC_CONNECTION_TIMEOUT, TIMEZONE_PRIORITY_GEOLOCATION, \ + DisplayModes from pyanaconda.modules.common.structures.timezone import GeolocationData class StartupUtilsTestCase(unittest.TestCase): @@ -303,3 +305,63 @@ def test_apply_tz_missing(self, has_trans_mock, setup_locale_mock, geodata_mock, assert tz_proxy.Timezone == "" setup_locale_mock.assert_called_once_with("es_ES.UTF-8", loc_proxy, text_mode=False) assert os.environ == {"LANG": "es_ES.UTF-8"} + + +class TestUIHelpers(unittest.TestCase): + + @patch("pyanaconda.startup_utils.pkgutil") + @patch("pyanaconda.startup_utils.flags") + def test_fallback_tui_when_gtk_ui_not_available(self, mocked_flags, mocked_pkgutil): + mocked_anaconda = Mock() + + def check_method(gui_mode, + webui_supported, + gtk_available, + expected_display_mode, + expected_rd_output): + mocked_anaconda.gui_mode = gui_mode + mocked_anaconda.is_webui_supported = webui_supported + + # prefilled values + mocked_anaconda.display_mode = "" + mocked_flags.use_rd = None + mocked_flags.rd_question = None + + if gtk_available: + mocked_pkgutil.iter_modules.return_value = [(None, "pyanaconda.ui.gui")] + else: + mocked_pkgutil.iter_modules.return_value = [(None, "pyanaconda.ui.webui")] + + fallback_to_tui_if_gtk_ui_is_not_available(mocked_anaconda) + + assert mocked_flags.use_rd is expected_rd_output + assert mocked_flags.rd_question is expected_rd_output + assert mocked_anaconda.display_mode == expected_display_mode + + # UI is not wanted + check_method(gui_mode=False, + webui_supported=False, + gtk_available=True, + expected_display_mode="", + expected_rd_output=None) + + # check result when web ui is supported + check_method(gui_mode=True, + webui_supported=True, + gtk_available=True, + expected_display_mode="", + expected_rd_output=None) + + # check result when gtk UI is not available + check_method(gui_mode=True, + webui_supported=False, + gtk_available=False, + expected_display_mode=DisplayModes.TUI, + expected_rd_output=False) + + # check result when GTK is available + check_method(gui_mode=True, + webui_supported=False, + gtk_available=True, + expected_display_mode="", + expected_rd_output=None) From efd866c3f9321de2a453938af83dcd8dfa419f5c Mon Sep 17 00:00:00 2001 From: Jiri Konecny Date: Tue, 3 Sep 2024 11:52:26 +0200 Subject: [PATCH 25/31] Remove Wayland detection logic from code This logic was used to disable keyboard switching for given system. However, we we support even Wayland systems with the new solution, so let's remove this. --- anaconda.spec.in | 1 - pyanaconda/core/configuration/system.py | 5 --- pyanaconda/keyboard.py | 43 +++---------------- .../pyanaconda_tests/test_keyboard.py | 38 +--------------- 4 files changed, 6 insertions(+), 81 deletions(-) diff --git a/anaconda.spec.in b/anaconda.spec.in index 723c46d458e..e5eb871ffdd 100644 --- a/anaconda.spec.in +++ b/anaconda.spec.in @@ -174,7 +174,6 @@ BuildRequires: desktop-file-utils # live installation currently implies a graphical installation Requires: anaconda-gui = %{version}-%{release} Requires: zenity -Requires: xisxwayland Recommends: xhost %description live diff --git a/pyanaconda/core/configuration/system.py b/pyanaconda/core/configuration/system.py index b1205ce8d6d..5ae8e981d56 100644 --- a/pyanaconda/core/configuration/system.py +++ b/pyanaconda/core/configuration/system.py @@ -132,11 +132,6 @@ def can_configure_keyboard(self): """Can we configure the keyboard?""" return self._is_boot_iso or self._is_live_os or self._is_booted_os - @property - def can_run_on_xwayland(self): - """Could we run on XWayland?""" - return self._is_live_os - @property def can_modify_syslog(self): """Can we modify syslog?""" diff --git a/pyanaconda/keyboard.py b/pyanaconda/keyboard.py index 0705c784406..353ad110b48 100644 --- a/pyanaconda/keyboard.py +++ b/pyanaconda/keyboard.py @@ -26,7 +26,6 @@ from pyanaconda.core.configuration.anaconda import conf from pyanaconda import localization from pyanaconda.core.constants import DEFAULT_KEYBOARD -from pyanaconda.core.util import execWithRedirect from pyanaconda.modules.common.task import sync_run_task from pyanaconda.modules.common.constants.services import LOCALIZATION @@ -56,47 +55,15 @@ class InvalidLayoutVariantSpec(Exception): pass -def _is_xwayland(): - """Is Anaconda running in XWayland environment? - - This can't be easily detected from the Anaconda because Anaconda - is running as XWayland app. Use xisxwayland tool for the detection. - """ - try: - rc = execWithRedirect('xisxwayland', []) - - if rc == 0: - return True - - log.debug( - "Anaconda doesn't run on XWayland. " - "See xisxwayland --help for more info." - ) - except FileNotFoundError: - log.warning( - "The xisxwayland tool is not available! " - "Taking the environment as not Wayland." - ) - - return False - - def can_configure_keyboard(): """Can we configure the keyboard? - FIXME: This is a temporary solution. - - The is_wayland logic is not part of the configuration so we would - have to add it to the configuration otherwise it won't be accessible - in the Anaconda modules. + NOTE: + This function could be inlined, however, this give us a possibility for future limitation + when needed. For example we could use this method to limit keyboard configuration if we + are able to detect that current system doesn't support localed keyboard layout switching. """ - if not conf.system.can_configure_keyboard: - return False - - if conf.system.can_run_on_xwayland and _is_xwayland(): - return False - - return True + return conf.system.can_configure_keyboard def parse_layout_variant(layout_variant_str): diff --git a/tests/unit_tests/pyanaconda_tests/test_keyboard.py b/tests/unit_tests/pyanaconda_tests/test_keyboard.py index b97aef06649..798732fafa4 100644 --- a/tests/unit_tests/pyanaconda_tests/test_keyboard.py +++ b/tests/unit_tests/pyanaconda_tests/test_keyboard.py @@ -26,51 +26,15 @@ class KeyboardUtilsTestCase(unittest.TestCase): """Test the keyboard utils.""" @patch("pyanaconda.keyboard.conf") - @patch("pyanaconda.keyboard.execWithRedirect") - def test_can_configure_keyboard(self, exec_mock, conf_mock): + def test_can_configure_keyboard(self, conf_mock): """Check if the keyboard configuration is enabled or disabled.""" # It's a dir installation. conf_mock.system.can_configure_keyboard = False - conf_mock.system.can_run_on_xwayland = False assert keyboard.can_configure_keyboard() is False - exec_mock.assert_not_called() # It's a boot.iso. conf_mock.system.can_configure_keyboard = True - conf_mock.system.can_run_on_xwayland = False assert keyboard.can_configure_keyboard() is True - exec_mock.assert_not_called() - - # It's a Live installation on Wayland. - conf_mock.system.can_configure_keyboard = True - conf_mock.system.can_run_on_xwayland = True - exec_mock.return_value = 0 - assert keyboard.can_configure_keyboard() is False - exec_mock.assert_called_once_with('xisxwayland', []) - exec_mock.reset_mock() - - # It's a Live installation and not on Wayland. - conf_mock.system.can_configure_keyboard = True - conf_mock.system.can_run_on_xwayland = True - exec_mock.return_value = 1 # xisxwayland returns 1 if it is not XWayland - assert keyboard.can_configure_keyboard() is True - exec_mock.assert_called_once_with('xisxwayland', []) - exec_mock.reset_mock() - - # It's a Live installation and probably not on Wayland, - # because the xisxwayland tooling is not present. - conf_mock.system.can_configure_keyboard = True - conf_mock.system.can_run_on_xwayland = True - exec_mock.side_effect = FileNotFoundError() - - with self.assertLogs(level="WARNING") as cm: - keyboard.can_configure_keyboard() - - msg = "The xisxwayland tool is not available!" - assert any(map(lambda x: msg in x, cm.output)) - - exec_mock.assert_called_once_with('xisxwayland', []) - exec_mock.reset_mock() class ParsingAndJoiningTests(unittest.TestCase): From 870942d18a3feb75047b964e63a593f3eaca9e68 Mon Sep 17 00:00:00 2001 From: Jiri Konecny Date: Tue, 3 Sep 2024 13:45:17 +0200 Subject: [PATCH 26/31] Set --rdp in liveinst unsupported Previously it was vnc but we switched to RDP instead. --- data/liveinst/liveinst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/liveinst/liveinst b/data/liveinst/liveinst index 2959d407a0c..f42b1e713c6 100755 --- a/data/liveinst/liveinst +++ b/data/liveinst/liveinst @@ -113,9 +113,9 @@ for opt in $(cat /proc/cmdline) "$@"; do fi exit 1 ;; - vnc|--vnc) + rdp|rdp.username|rdp.password|--rdp|--rdp.username|--rdp.password) title="Configuration not supported" - text="VNC is not supported on live media." + text="RDP is not supported on live media." if which zenity &> /dev/null; then zenity --warning --title="$title" --text="$text" else From cedfa2c4eba95a00c507c496762fca04532a7fe1 Mon Sep 17 00:00:00 2001 From: Jiri Konecny Date: Wed, 14 Aug 2024 18:22:13 +0200 Subject: [PATCH 27/31] Add release-notes for Wayland migration https://fedoraproject.org/wiki/Changes/Anaconda_As_Native_Wayland_Application --- docs/release-notes/rdp-support.rst | 16 ++++++++++++++++ docs/release-notes/wayland-migration.rst | 13 +++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 docs/release-notes/rdp-support.rst create mode 100644 docs/release-notes/wayland-migration.rst diff --git a/docs/release-notes/rdp-support.rst b/docs/release-notes/rdp-support.rst new file mode 100644 index 00000000000..fc51e5dd8d8 --- /dev/null +++ b/docs/release-notes/rdp-support.rst @@ -0,0 +1,16 @@ +:Type: GUI +:Summary: Replace VNC with RDP (#2231339) + +:Description: + As part of the X11 dependencies removals, Anaconda also drops VNC. As a replacement + RDP (Remote Desktop Protocol) is implemented. + + What has changed: + - Adding new kernel boot arguments: ``inst.rdp``, ``inst.rdp.username``, ``inst.rdp.password``. + - Drop existing kernel boot argument: ``inst.vnc``, ``inst.vncpassword``, ``inst.vncconnect``. + - Drop the existing ``vnc`` kickstart command. + +:Links: + - https://fedoraproject.org/wiki/Changes/Anaconda_As_Native_Wayland_Application + - https://github.com/rhinstaller/anaconda/pull/5829 + - https://bugzilla.redhat.com/show_bug.cgi?id=1955025 diff --git a/docs/release-notes/wayland-migration.rst b/docs/release-notes/wayland-migration.rst new file mode 100644 index 00000000000..0af19d15437 --- /dev/null +++ b/docs/release-notes/wayland-migration.rst @@ -0,0 +1,13 @@ +:Type: GUI +:Summary: Migrate Anaconda to Wayland application (#2231339) + +:Description: + This change enables Anaconda to run natively on Wayland. Previously, Anaconda operated as an + Xorg application or relied on XWayland for support. + + By implementing this update, we can eliminate dependencies on X11 and embrace newer, more + secure technologies. + +:Links: + - https://fedoraproject.org/wiki/Changes/Anaconda_As_Native_Wayland_Application + - https://github.com/rhinstaller/anaconda/pull/5829 From 4cd89b387a893194601a034f46147431d8c821a6 Mon Sep 17 00:00:00 2001 From: Jiri Konecny Date: Fri, 13 Sep 2024 13:21:14 +0200 Subject: [PATCH 28/31] Do not change compositor options when not defined If Anaconda will set keyboard layouts to compositor but options are missed then we shouldn't change the compositor options but rather use what is already set. This will avoid problematic behavior of changing what user has defined in the system or similar cases. Also we have this tested in kickstart-tests, so this commit is fixing these tests. Also fix existing tests and cover this functionality by tests. --- pyanaconda/modules/localization/localed.py | 13 +++++-- .../localization/test_localed_wrapper.py | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/pyanaconda/modules/localization/localed.py b/pyanaconda/modules/localization/localed.py index 2f4a43daeac..d49c9a1b938 100644 --- a/pyanaconda/modules/localization/localed.py +++ b/pyanaconda/modules/localization/localed.py @@ -228,9 +228,6 @@ def _set_layouts(self, layouts_variants, options=None, convert=False): variants = [] parsing_failed = False - log.debug("Setting system/compositor keyboard layouts: '%s' options: '%s' convert: '%s", - layouts_variants, options, convert) - for layout_variant in (nonempty for nonempty in layouts_variants if nonempty): try: (layout, variant) = parse_layout_variant(layout_variant) @@ -244,9 +241,17 @@ def _set_layouts(self, layouts_variants, options=None, convert=False): if not layouts and parsing_failed: return + if options is None: + options = self.options + log.debug("Keyboard layouts for system/compositor are missing options. " + "Use compositor options: %s", options) + layouts_str = ",".join(layouts) variants_str = ",".join(variants) - options_str = ",".join(options) if options else "" + options_str = ",".join(options) + + log.debug("Setting system/compositor keyboard layouts: '%s' options: '%s' convert: '%s", + layouts_variants, options, convert) self._localed_proxy.SetX11Keyboard( layouts_str, diff --git a/tests/unit_tests/pyanaconda_tests/modules/localization/test_localed_wrapper.py b/tests/unit_tests/pyanaconda_tests/modules/localization/test_localed_wrapper.py index 97a77fba370..ac73f31b5a3 100644 --- a/tests/unit_tests/pyanaconda_tests/modules/localization/test_localed_wrapper.py +++ b/tests/unit_tests/pyanaconda_tests/modules/localization/test_localed_wrapper.py @@ -128,6 +128,41 @@ def test_localed_wrapper_safe_calls(self, mocked_conf, mocked_localed_service, localed_wrapper.set_layouts(["cz", "us (euro)"]) assert localed_wrapper._user_layouts_variants == ["cz", "us (euro)"] + # test set_layout on proxy with options + mocked_localed_proxy.SetX11Keyboard.reset_mock() + localed_wrapper.set_layouts(["cz (qwerty)", "us"]) + mocked_localed_proxy.SetX11Keyboard.assert_called_once_with( + "cz,us", + "pc105", # hardcoded + "qwerty,", + "grp:alt_shift_toggle,grp:ctrl_alt_toggle", # options will be reused what is set + False, + False + ) + + # test set_layout on proxy with options not set explicitly (None) + mocked_localed_proxy.SetX11Keyboard.reset_mock() + localed_wrapper.set_layouts(["cz (qwerty)", "us"], options=None) + mocked_localed_proxy.SetX11Keyboard.assert_called_once_with( + "cz,us", + "pc105", # hardcoded + "qwerty,", + "grp:alt_shift_toggle,grp:ctrl_alt_toggle", # options will be reused what is set + False, + False + ) + + mocked_localed_proxy.SetX11Keyboard.reset_mock() + localed_wrapper.set_layouts(["us"], "", True) + mocked_localed_proxy.SetX11Keyboard.assert_called_once_with( + "us", + "pc105", # hardcoded + "", + "", # empty options will remove existing options + True, + False + ) + @patch("pyanaconda.modules.localization.localed.SystemBus") def test_localed_wrapper_no_systembus(self, mocked_system_bus): """Test LocaledWrapper in environment without system bus. @@ -155,6 +190,7 @@ def test_localed_wrapper_set_current_layout(self, mocked_conf, mocked_localed_service.get_proxy.return_value = mocked_localed_proxy mocked_localed_proxy.X11Layout = "cz,fi,us,fr" mocked_localed_proxy.X11Variant = "qwerty,,euro" + mocked_localed_proxy.X11Options = "" localed_wrapper = LocaledWrapper() user_defined = ["cz (qwerty)", "fi", "us (euro)", "fr"] @@ -190,6 +226,7 @@ def test_localed_wrapper_set_current_layout(self, mocked_conf, mocked_localed_proxy.SetX11Keyboard.reset_mock() mocked_localed_proxy.X11Layout = "fi" mocked_localed_proxy.X11Variant = "" + mocked_localed_proxy.X11Options = "" localed_wrapper._user_layouts_variants = user_defined assert localed_wrapper.set_current_layout("cz") is False @@ -217,6 +254,7 @@ def test_localed_wrapper_set_current_layout(self, mocked_conf, mocked_localed_proxy.SetX11Keyboard.reset_mock() mocked_localed_proxy.X11Layout = "cz, us" mocked_localed_proxy.X11Variant = "" + mocked_localed_proxy.X11Options = "" user_defined = [] localed_wrapper._user_layouts_variants = user_defined @@ -243,6 +281,7 @@ def test_localed_wrapper_set_next_layout(self, mocked_conf, # currently selected is first in this list 'cz (qwerty)' mocked_localed_proxy.X11Layout = "cz,fi,us,fr" mocked_localed_proxy.X11Variant = "qwerty,,euro" + mocked_localed_proxy.X11Options = "" localed_wrapper = LocaledWrapper() # test switch to next layout From d96340092ed58782f9612e7994231d9ac06d657a Mon Sep 17 00:00:00 2001 From: Jiri Konecny Date: Tue, 17 Sep 2024 16:24:41 +0200 Subject: [PATCH 29/31] Fix typo in the GRD source file name --- anaconda.py | 4 ++-- pyanaconda/display.py | 2 +- .../{gnome_remote_destop.py => gnome_remote_desktop.py} | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename pyanaconda/{gnome_remote_destop.py => gnome_remote_desktop.py} (100%) diff --git a/anaconda.py b/anaconda.py index d9f66c363ad..133db87f9b4 100755 --- a/anaconda.py +++ b/anaconda.py @@ -51,7 +51,7 @@ def exitHandler(rebootData): # pylint: disable=possibly-used-before-assignment # pylint: disable=used-before-assignment if flags.use_rd: - gnome_remote_destop.shutdown_server() + gnome_remote_desktop.shutdown_server() # pylint: disable=possibly-used-before-assignment # pylint: disable=used-before-assignment @@ -283,7 +283,7 @@ def setup_environment(): opts.display_mode = constants.DisplayModes.TUI opts.noninteractive = True - from pyanaconda import gnome_remote_destop + from pyanaconda import gnome_remote_desktop from pyanaconda import kickstart # we are past the --version and --help shortcut so we can import display & # startup_utils, which import Blivet, without slowing down anything critical diff --git a/pyanaconda/display.py b/pyanaconda/display.py index abcf926192b..4499ebd783f 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -30,7 +30,7 @@ from pyanaconda.core.process_watchers import WatchProcesses from pyanaconda import startup_utils from pyanaconda.core import util, constants, hw -from pyanaconda.gnome_remote_destop import GRDServer +from pyanaconda.gnome_remote_desktop import GRDServer from pyanaconda.core.i18n import _ from pyanaconda.flags import flags from pyanaconda.modules.common.constants.services import NETWORK diff --git a/pyanaconda/gnome_remote_destop.py b/pyanaconda/gnome_remote_desktop.py similarity index 100% rename from pyanaconda/gnome_remote_destop.py rename to pyanaconda/gnome_remote_desktop.py From da20f70ebbcae0201c49fecf11302855e1dd0c38 Mon Sep 17 00:00:00 2001 From: Jiri Konecny Date: Tue, 24 Sep 2024 17:43:30 +0200 Subject: [PATCH 30/31] Disable fedora-cisco repository in our containers We need to disable these repositories to avoid dependencies from it. With this changeset we depends on gnome-remote-desktop which depends on librdp which depends (Fedora only) on openh264. However, if fedora-cisco repository is not enabled it will instead install noopenh264 package which is a stub package in the main repositories. --- dockerfile/anaconda-ci/Dockerfile | 4 ++++ dockerfile/anaconda-release/Dockerfile | 4 ++++ dockerfile/anaconda-rpm/Dockerfile | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/dockerfile/anaconda-ci/Dockerfile b/dockerfile/anaconda-ci/Dockerfile index 81354df82ff..dbfb8e1bbe0 100644 --- a/dockerfile/anaconda-ci/Dockerfile +++ b/dockerfile/anaconda-ci/Dockerfile @@ -32,6 +32,10 @@ COPY ["anaconda.spec.in", "requirements.txt", "/root/"] # Prepare environment and install build dependencies RUN set -ex; \ + # disable fedora-cisco repository otherwise freerdp will depend on openh264 from fedora-cisco + # if fedora-cisco is not enabled it will fallback to stub library in main repository which is + # what we want + sed -i 's/enabled=1/enabled=0/' /etc/yum.repos.d/fedora-cisco-openh264.repo; \ dnf install -y \ 'dnf5-command(copr)'; \ # Enable COPR repositories diff --git a/dockerfile/anaconda-release/Dockerfile b/dockerfile/anaconda-release/Dockerfile index ed26b0756df..19ec7833b4e 100644 --- a/dockerfile/anaconda-release/Dockerfile +++ b/dockerfile/anaconda-release/Dockerfile @@ -10,6 +10,10 @@ LABEL maintainer=anaconda-list@redhat.com # Add missing dependencies required to do the build. RUN set -e; \ dnf update -y; \ + # disable fedora-cisco repository otherwise freerdp will depend on openh264 from fedora-cisco + # if fedora-cisco is not enabled it will fallback to stub library in main repository which is + # what we want + sed -i 's/enabled=1/enabled=0/' /etc/yum.repos.d/fedora-cisco-openh264.repo; \ dnf install -y \ git \ python3-pip; \ diff --git a/dockerfile/anaconda-rpm/Dockerfile b/dockerfile/anaconda-rpm/Dockerfile index edbe82efd1a..e887a2412b9 100644 --- a/dockerfile/anaconda-rpm/Dockerfile +++ b/dockerfile/anaconda-rpm/Dockerfile @@ -27,6 +27,10 @@ COPY ["anaconda.spec.in", "/root/"] # Prepare environment and install build dependencies RUN set -ex; \ + # disable fedora-cisco repository otherwise freerdp will depend on openh264 from fedora-cisco + # if fedora-cisco is not enabled it will fallback to stub library in main repository which is + # what we want + sed -i 's/enabled=1/enabled=0/' /etc/yum.repos.d/fedora-cisco-openh264.repo; \ dnf update -y; \ # Install dependencies dnf install -y \ From db576ec8ea6ea3e5e5154a427333b1337e8074a8 Mon Sep 17 00:00:00 2001 From: Jiri Konecny Date: Wed, 25 Sep 2024 17:37:04 +0200 Subject: [PATCH 31/31] Create GRDServer class only when required Move the GRDServer class creation to the end of the `setup_display` method. This class have checks for binaries in the `__init__` method which is causing early failures. Also do not create the class if it is not really used. --- pyanaconda/display.py | 54 +++++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/pyanaconda/display.py b/pyanaconda/display.py index 4499ebd783f..01677fb09a9 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -24,6 +24,8 @@ import textwrap import signal +from collections import namedtuple + from pyanaconda.mutter_display import MutterDisplay, MutterConfigError from pyanaconda.core.configuration.anaconda import conf from pyanaconda.core.path import join_paths @@ -48,6 +50,10 @@ log = get_module_logger(__name__) stdout_log = get_stdout_logger() + +rdp_credentials = namedtuple("rdp_credentials", ["username", "password"]) + + WAYLAND_TIMEOUT_ADVICE = \ "Do not load the stage2 image over a slow network link.\n" \ "Wait longer for Wayland startup with the inst.xtimeout= boot option." \ @@ -84,15 +90,21 @@ def start_user_systemd(): # RDP -def ask_rd_question(anaconda, grd_server, message): +def ask_rd_question(anaconda, message): """ Ask the user if TUI or GUI-over-RDP should be started. + Return Tuple(should use RDP, NameTuple rdp_credentials(username, password)) + + e.g.: + (True, rdp_credentials) + rdp_credentials.username + rdp_credentials.password + :param anaconda: instance of the Anaconda class - :param grd_server: instance of the GRD server object :param str message: a message to show to the user together with the question - :return: if remote desktop should be used - :rtype: bool + :return: (use_rd, rdp_credentials(username, password)) + :rtype: Tuple(bool, NameTuple(username, password)) """ App.initialize() loop = App.get_event_loop() @@ -107,19 +119,18 @@ def ask_rd_question(anaconda, grd_server, message): log.info("RDP requested via RDP question, switching Anaconda to GUI mode.") anaconda.display_mode = constants.DisplayModes.GUI flags.use_rd = True - grd_server.rdp_username = spoke.rdp_username - grd_server.rdp_password = spoke.rdp_password - return spoke.use_remote_desktop + return (spoke.use_remote_desktop, rdp_credentials(spoke.rdp_username, spoke.rdp_password)) -def ask_for_rd_credentials(anaconda, grd_server, username=None, password=None): +def ask_for_rd_credentials(anaconda, username=None, password=None): """ Ask the user to provide RDP credentials interactively. :param anaconda: instance of the Anaconda class - :param grd_server: instance of the GRD server object :param str username: user set username (if any) :param str password: user set password (if any) + + :return: namedtuple rdp_credentials(username, password) """ App.initialize() loop = App.get_event_loop() @@ -131,8 +142,7 @@ def ask_for_rd_credentials(anaconda, grd_server, username=None, password=None): log.info("RDP credentials set") anaconda.display_mode = constants.DisplayModes.GUI flags.use_rd = True - grd_server.rdp_username = spoke._username - grd_server.rdp_password = spoke._password + return rdp_credentials(spoke._username, spoke._password) def check_rd_can_be_started(anaconda): @@ -300,16 +310,15 @@ def setup_display(anaconda, options): log.warning("invalid inst.xtimeout option value: %s", options.xtimeout) xtimeout = constants.X_TIMEOUT - grd_server = GRDServer(anaconda) # The RDP server object rdp_credentials_sufficient = False + rdp_creds = rdp_credentials("", "") if options.rdp_enabled: flags.use_rd = True if not anaconda.gui_mode: log.info("RDP requested via boot/CLI option, switching Anaconda to GUI mode.") anaconda.display_mode = constants.DisplayModes.GUI - grd_server.rdp_username = options.rdp_username - grd_server.rdp_password = options.rdp_password + rdp_creds = rdp_credentials(options.rdp_username, options.rdp_password) # note if we have both set rdp_credentials_sufficient = options.rdp_username and options.rdp_password @@ -324,10 +333,9 @@ def setup_display(anaconda, options): # or inst.rdp and insufficient credentials are provided # via boot options, ask interactively. if options.rdp_enabled and not rdp_credentials_sufficient: - ask_for_rd_credentials(anaconda, - grd_server, - options.rdp_username, - options.rdp_password) + rdp_creds = ask_for_rd_credentials(anaconda, + options.rdp_username, + options.rdp_password) else: # RDP can't be started - disable the RDP question and log # all the errors that prevented RDP from being started @@ -341,9 +349,12 @@ def setup_display(anaconda, options): "options. It does not offer custom partitioning for " "full control over the disk layout. Would you like " "to use remote graphical access via the RDP protocol instead?") - if not ask_rd_question(anaconda, grd_server, message): + use_rd, credentials = ask_rd_question(anaconda, message) + if not use_rd: # user has explicitly specified text mode flags.rd_question = False + else: + rdp_creds = credentials anaconda.log_display_mode() startup_utils.check_memory(anaconda, options) @@ -389,11 +400,14 @@ def on_mutter_ready(observer): "an RDP session to connect to this computer from another computer and " "perform a graphical installation or continue with a text mode " "installation?") - ask_rd_question(anaconda, grd_server, message) + rdp_creds = ask_rd_question(anaconda, message) # if they want us to use RDP do that now if anaconda.gui_mode and flags.use_rd: do_startup_wl_actions(xtimeout, headless=True, headless_resolution=options.runres) + grd_server = GRDServer(anaconda) # The RDP server object + grd_server.rdp_username = rdp_creds.username + grd_server.rdp_password = rdp_creds.password grd_server.start_grd_rdp() # with Wayland running we can initialize the UI interface