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.py b/anaconda.py index e438f6aa8ed..133db87f9b4 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. @@ -40,8 +50,8 @@ def exitHandler(rebootData): # pylint: disable=possibly-used-before-assignment # pylint: disable=used-before-assignment - if flags.usevnc: - vnc.shutdownServer() + if flags.use_rd: + gnome_remote_desktop.shutdown_server() # pylint: disable=possibly-used-before-assignment # pylint: disable=used-before-assignment @@ -150,6 +160,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 @@ -163,7 +179,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" - if __name__ == "__main__": # check if the CLI help is requested and return it at once, # without importing random stuff and spamming stdout @@ -268,7 +283,7 @@ def setup_environment(): opts.display_mode = constants.DisplayModes.TUI opts.noninteractive = True - from pyanaconda import vnc + 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 @@ -306,10 +321,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 efe1d35bf83..e5eb871ffdd 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 @@ -139,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 @@ -176,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 @@ -256,16 +253,19 @@ 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 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 Requires: rpm-ostree >= %{rpmostreever} Requires: ostree @@ -290,8 +290,6 @@ Requires: python3-meh-gui >= %{mehver} 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 @@ -303,6 +301,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. @@ -409,6 +409,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/anaconda_options.txt b/data/anaconda_options.txt index d9e1cfe09e6..c1cef37e72c 100644 --- a/data/anaconda_options.txt +++ b/data/anaconda_options.txt @@ -93,30 +93,20 @@ 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. - -vncconnect -Once installation is up and running, connect to the VNC client named HOST, and optionally use port PORT. - -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). +xtimeout +Specify the timeout in seconds for starting X server or Wayland compositor. -xdriver -Use DRIVER as the X driver to use during installation as well as on the installed system. +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. -xtimeout -Specify the timeout in seconds for starting X server. +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/data/liveinst/liveinst b/data/liveinst/liveinst index eb6caa389b3..f42b1e713c6 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=}" ;; @@ -116,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 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-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/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/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 \ diff --git a/docs/boot-options.rst b/docs/boot-options.rst index 546d6b3b90f..dfb04605f32 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 @@ -551,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 @@ -560,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/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 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/argument_parsing.py b/pyanaconda/argument_parsing.py index 0e6ad26366b..302b2b9f375 100644 --- a/pyanaconda/argument_parsing.py +++ b/pyanaconda/argument_parsing.py @@ -489,17 +489,14 @@ 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", + 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")) diff --git a/pyanaconda/core/configuration/system.py b/pyanaconda/core/configuration/system.py index caedd1e5fa3..5ae8e981d56 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?""" @@ -127,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/core/constants.py b/pyanaconda/core/constants.py index 06ff8cb0416..5232e6d87d9 100644 --- a/pyanaconda/core/constants.py +++ b/pyanaconda/core/constants.py @@ -83,8 +83,8 @@ DRACUT_SHUTDOWN_EJECT = "/run/initramfs/usr/lib/dracut/hooks/shutdown/99anaconda-eject.sh" -# VNC questions -USEVNC = N_("Start VNC") +# RDP questions +USERDP = N_("Use graphical mode via Remote Desktop Protocol") USETEXT = N_("Use text mode") # Quit message @@ -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/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/core/util.py b/pyanaconda/core/util.py index 81a040d1938..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_x11_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): @@ -354,8 +258,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, env_add=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,8 +273,8 @@ 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, - filter_stderr=filter_stderr, do_preexec=do_preexec)[1] + return _run_program(argv, stdin=stdin, root=root, log_output=log_output, env_prune=env_prune, + 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 c527b66b577..01677fb09a9 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -20,47 +20,49 @@ # Author(s): Martin Kolman # import os -import subprocess import time import textwrap -import pkgutil 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 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_desktop 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 import blivet 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() -X_TIMEOUT_ADVICE = \ + +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 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" \ "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. @@ -85,129 +87,118 @@ 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 +def ask_rd_question(anaconda, message): + """ Ask the user if TUI or GUI-over-RDP should be started. -# VNC + Return Tuple(should use RDP, NameTuple rdp_credentials(username, password)) -def ask_vnc_question(anaconda, vnc_server, message): - """ Ask the user if TUI or GUI-over-VNC should be started. + e.g.: + (True, rdp_credentials) + rdp_credentials.username + rdp_credentials.password :param anaconda: instance of the Anaconda class - :param vnc_server: instance of the VNC server object :param str message: a message to show to the user together with the question + :return: (use_rd, rdp_credentials(username, password)) + :rtype: Tuple(bool, NameTuple(username, password)) """ 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.usevnc = True - vnc_server.password = vnc_data.password.value + flags.use_rd = True + + return (spoke.use_remote_desktop, rdp_credentials(spoke.rdp_username, spoke.rdp_password)) + + +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 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() + loop.set_quit_callback(tui_quit_callback) + spoke = RDPAuthSpoke(anaconda.ksdata, username=username, password=password) + ScreenHandler.schedule_screen(spoke) + App.run() -def check_vnc_can_be_started(anaconda): - """Check if we can start VNC in the current environment. + log.info("RDP credentials set") + anaconda.display_mode = constants.DisplayModes.GUI + flags.use_rd = True + return rdp_credentials(spoke._username, spoke._password) - :returns: if VNC can be started and list of possible reasons - why VNC can't be started + +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 - - # 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 - - return vnc_startup_possible, error_messages + 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 + 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 -# X11 + return rd_startup_possible, error_messages -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) +def do_startup_wl_actions(timeout, headless=False, headless_resolution=None): + """Start the Wayland compositor. - -# function to handle X startup special issues for anaconda - -def do_startup_x11_actions(): - """Start the window manager. - - 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. + + :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: @@ -220,55 +211,69 @@ def do_startup_x11_actions(): 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 x11_preexec(): + 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") + # lets compile arguments for the run-in-new-session script + argv = ["/usr/libexec/anaconda/run-in-new-session", + "--user", "root", + "--service", "anaconda", + "--session-type", "wayland", + "--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"]) -def set_x_resolution(runres): - """Set X server screen resolution. + # add the generic GNOME Kiosk invocation + argv.extend(["gnome-kiosk", "--sm-disable", + "--wayland", "--no-x11", + "--wayland-display", constants.WAYLAND_SOCKET_NAME]) - :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"]) + # remote access needs gnome-kiosk to start in headless mode + 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) -def do_extra_x11_actions(runres, gui_mode): - """Perform X11 actions not related to startup. + childproc = util.startProgram(argv, env_add={'XDG_DATA_DIRS': xdg_data_dirs}, + preexec_fn=wl_preexec, + stdout=gnome_kiosk_stdout_stream, + stderr=gnome_kiosk_stderr_stream, + ) + WatchProcesses.watch_process(childproc, argv[0]) - :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) + 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 - # Load the system-wide Xresources - util.execWithRedirect("xrdb", ["-nocpp", "-merge", "/etc/X11/Xresources"]) - start_spice_vd_agent() + time.sleep(0.1) + WatchProcesses.unwatch_process(childproc) + childproc.terminate() + raise TimeoutError("Timeout trying to start gnome-kiosk") -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) +def set_resolution(runres): + """Set the screen resolution. - f = open("%s/etc/X11/xorg.conf" % (root,), 'w') - f.write('Section "Device"\n\tIdentifier "Videocard0"\n\tDriver "%s"\nEndSection\n' % driver) - f.close() + :param str runres: a resolution specification string + """ + try: + log.info("Setting the screen resolution to: %s.", runres) + mutter_display = MutterDisplay() + mutter_display.set_resolution(runres) + except MutterConfigError as error: + log.error("The resolution was not set: %s", error) # general display startup @@ -281,6 +286,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 @@ -289,108 +296,81 @@ 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: 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 + rdp_credentials_sufficient = False + rdp_creds = rdp_credentials("", "") - if options.vnc: - flags.usevnc = True + 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] - - 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.usevnc = 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 + 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 # 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.usevnc = False - flags.vncquestion = 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 - for error_message in vnc_error_messages: + 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) + + 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: + 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 + flags.rd_question = False + for error_message in rd_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 + if anaconda.tui_mode and flags.rd_question: + # 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?") + use_rd, credentials = ask_rd_question(anaconda, message) + if not use_rd: # user has explicitly specified text mode - flags.vncquestion = False + flags.rd_question = False + else: + rdp_creds = credentials anaconda.log_display_mode() 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.use_rd) + 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,29 +378,39 @@ 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 time.sleep(2) if not anaconda.gui_startup_failed: - do_extra_x11_actions(options.runres, gui_mode=anaconda.gui_mode) - - if anaconda.tui_mode and anaconda.gui_startup_failed and \ - flags.vncquestion 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: - vnc_server.startServer() - do_startup_x11_actions() - - # with X running we can initialize the UI interface + if options.runres and anaconda.gui_mode and not flags.use_rd: + 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.rd_question: + + 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?") + 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 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/flags.py b/pyanaconda/flags.py index 000fd5df876..289c2cd3a0d 100644 --- a/pyanaconda/flags.py +++ b/pyanaconda/flags.py @@ -33,8 +33,9 @@ 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 self.eject = True diff --git a/pyanaconda/gnome_remote_desktop.py b/pyanaconda/gnome_remote_desktop.py new file mode 100644 index 00000000000..8cff8351397 --- /dev/null +++ b/pyanaconda/gnome_remote_desktop.py @@ -0,0 +1,219 @@ +# +# 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 +import socket + +from systemd import journal +from pyanaconda import network +from pyanaconda.core import util +from pyanaconda.core.util import execWithCapture, startProgram + +from pyanaconda.core.i18n import _ + +from pyanaconda.anaconda_loggers import get_stdout_logger, get_module_logger + +stdoutLog = get_stdout_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) + 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_add={"HOME": "/root"}) + + 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=grd_stdout_stream, + stderr=grd_stderr_stream, + env_add={"HOME": "/root"}) + 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() 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/pyanaconda/modules/common/constants/services.py b/pyanaconda/modules/common/constants/services.py index 468d4ef7684..72ed9b4890a 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. + +MUTTER_DISPLAY_CONFIG = DBusServiceIdentifier( + namespace=("org", "gnome", "Mutter", "DisplayConfig"), + message_bus=SessionBus +) diff --git a/pyanaconda/modules/localization/localed.py b/pyanaconda/modules/localization/localed.py index 10b4f95081a..d49c9a1b938 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, \ @@ -30,6 +31,10 @@ 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: " @@ -42,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): @@ -68,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 [] @@ -77,6 +128,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 +185,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 +215,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 @@ -175,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, @@ -198,7 +272,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 +297,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/pyanaconda/modules/localization/localization.py b/pyanaconda/modules/localization/localization.py index 80e93b676a2..6d8e44951d1 100644 --- a/pyanaconda/modules/localization/localization.py +++ b/pyanaconda/modules/localization/localization.py @@ -66,6 +66,9 @@ 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 def publish(self): @@ -244,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): @@ -315,3 +325,18 @@ def set_from_generic_keyboard_setting(self, keyboard): ) result = task.run() self._update_settings_from_task(result) + + def get_compositor_selected_layout(self): + return self.localed_wrapper.current_layout_variant + + def set_compositor_selected_layout(self, layout_variant): + return self.localed_wrapper.set_current_layout(layout_variant) + + def select_next_compositor_layout(self): + return self.localed_wrapper.select_next_layout() + + def get_compositor_layouts(self): + return self.localed_wrapper.layouts_variants + + def set_compositor_layouts(self, layout_variants, options): + self.localed_wrapper.set_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 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)) diff --git a/pyanaconda/startup_utils.py b/pyanaconda/startup_utils.py index 077de32f07e..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 @@ -137,7 +138,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 @@ -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. @@ -255,7 +272,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 @@ -552,7 +569,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. @@ -563,8 +580,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/__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 f17095e3240..66efb407bf1 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 @@ -351,8 +357,8 @@ 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: + # Not an issue for RDP, since RDP keymaps are weird and more on the client side. + if flags.flags.use_rd: return True # Not an issue where system keyboard configuration is not allowed @@ -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,29 +471,27 @@ 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): """ Remove the layout specified by store iterator from the store and - X runtime configuration. + Wayland runtime configuration. """ - if keyboard.can_configure_keyboard(): - self._xkl_wrapper.remove_layout(store[itr][0]) store.remove(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" + "supported when using RDP.\n" "However the settings will be used " "after the installation.")) elif switch_options: @@ -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) diff --git a/pyanaconda/ui/tui/spokes/askvnc.py b/pyanaconda/ui/tui/spokes/askrd.py similarity index 53% rename from pyanaconda/ui/tui/spokes/askvnc.py rename to pyanaconda/ui/tui/spokes/askrd.py index b0d906c1088..781817fc7a7 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._usevnc = False + 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): - self._usevnc = True - new_spoke = VNCPassSpoke(self.data, self.storage, self.payload, vnc_data=self.vnc_data) - ScreenHandler.push_screen_modal(new_spoke) + def _use_rdp_callback(self, data): + self._use_rd = True + 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._usevnc = False + 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._usevnc - 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 + .. inheritance-diagram:: RDPAuthSpoke :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/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 eac3a3f1719..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"] @@ -325,6 +326,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 +491,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/scripts/run-in-new-session b/scripts/run-in-new-session new file mode 100755 index 00000000000..97663060358 --- /dev/null +++ b/scripts/run-in-new-session @@ -0,0 +1,235 @@ +#!/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 +from systemd import journal + +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: + # 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(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) + + 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() 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/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) 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..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 @@ -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 @@ -66,6 +68,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 +79,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 +118,51 @@ 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)"] + + # 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. @@ -124,3 +173,370 @@ 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" + mocked_localed_proxy.X11Options = "" + 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 = "" + mocked_localed_proxy.X11Options = "" + 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 = "" + mocked_localed_proxy.X11Options = "" + 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" + mocked_localed_proxy.X11Options = "" + 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() + + @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"] + ) 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..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 @@ -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,50 @@ def test_keyboard_kickstart4(self): """ self._test_kickstart(ks_in, ks_out) + @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._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.SetCompositorSelectedLayout("cz (qwerty)") + # pylint: disable=no-member + manager_mock.set_current_layout.assert_called_once_with("cz (qwerty)") + + self.localization_interface.SelectNextCompositorLayout() + # pylint: disable=no-member + 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_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) + 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) + 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): """Test Localization module.""" 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/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): 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", diff --git a/widgets/configure.ac b/widgets/configure.ac index 4d3ecaf88c4..c38a3ae6db5 100644 --- a/widgets/configure.ac +++ b/widgets/configure.ac @@ -71,8 +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([LIBXKLAVIER], [libxklavier >= 5.2.1]) +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 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); diff --git a/widgets/src/Makefile.am b/widgets/src/Makefile.am index d5af7f74483..34fd0d4a5a2 100644 --- a/widgets/src/Makefile.am +++ b/widgets/src/Makefile.am @@ -47,18 +47,22 @@ 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"' 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) @@ -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 @@ -115,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 @@ -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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +