Skip to content
Snippets Groups Projects
  • Ludovico de Nittis's avatar
    d7ad67a2
    Initial support for custom graphics provider path and Flatpak · d7ad67a2
    Ludovico de Nittis authored
    
    pressure-vessel had a few hard coded assumptions about the host system
    and where to look at for the graphics provider.
    
    For example it was assumed that the root was the same host system used
    by both pressure-vessel current environment (where pressure-vessel-wrap
    runs) and also later on by `bwrap`.
    
    But when we run pressure-vessel in a Flatpak container, the final
    `bwrap` command is expected to be executed against the real host system
    (mounted in `/run/host`, in Flatpak).
    
    Also when we are in a Flatpak container we might want to pick the
    graphics stack from the host system (`/run/host`), from the Flatpak
    container itself or it could even be a completely separate one.
    (T22373, T22371)
    
    Signed-off-by: default avatarLudovico de Nittis <ludovico.denittis@collabora.com>
    d7ad67a2
    History
    Initial support for custom graphics provider path and Flatpak
    Ludovico de Nittis authored
    
    pressure-vessel had a few hard coded assumptions about the host system
    and where to look at for the graphics provider.
    
    For example it was assumed that the root was the same host system used
    by both pressure-vessel current environment (where pressure-vessel-wrap
    runs) and also later on by `bwrap`.
    
    But when we run pressure-vessel in a Flatpak container, the final
    `bwrap` command is expected to be executed against the real host system
    (mounted in `/run/host`, in Flatpak).
    
    Also when we are in a Flatpak container we might want to pick the
    graphics stack from the host system (`/run/host`), from the Flatpak
    container itself or it could even be a completely separate one.
    (T22373, T22371)
    
    Signed-off-by: default avatarLudovico de Nittis <ludovico.denittis@collabora.com>
pressure-vessel-test-ui 17.12 KiB
#!/usr/bin/env python3

# Copyright © 2019 Collabora Ltd.
#
# SPDX-License-Identifier: MIT
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

import logging
import os
import shlex
import subprocess
import sys

try:
    import typing
except ImportError:
    pass
else:
    typing      # silence pyflakes

import gi
gi.require_version('Gtk', '3.0')

from gi.repository import GLib
from gi.repository import Gtk

logger = logging.getLogger('pressure-vessel-test-ui')

assert sys.version_info >= (3, 4), 'Python 3.4+ is required for this script'


def tristate_environment(name):
    # type: (str) -> typing.Optional[bool]
    value = os.getenv(name)

    if value is None or value == '':
        return None

    if value == '1':
        return True

    if value == '0':
        return False

    logger.warning('Unrecognised value %r for $%s', value, name)
    return None


def boolean_environment(name, default):
    # type: (str, bool) -> bool
    value = os.getenv(name)

    if value is None:
        return default

    if value == '1':
        return True

    if value in ('', '0'):
        return False

    logger.warning('Unrecognised value %r for $%s', value, name)
    return default


class Gui:
    def __init__(self):
        # type: (...) -> None

        self.failed = False
        self.home = GLib.get_home_dir()

        self.container_runtimes = {}    # type: typing.Dict[str, str]

        for search in (
            os.getenv('PRESSURE_VESSEL_RUNTIME_BASE'),
            '..',
            '../..',
        ):
            if search is None:
                continue

            source_of_runtimes = os.path.join(
                os.path.dirname(__file__),
                search,
            )

            if not os.path.isdir(source_of_runtimes):
                continue

            for member in os.listdir(source_of_runtimes):
                path = os.path.realpath(
                    os.path.join(source_of_runtimes, member)
                )
                files = os.path.join(path, 'files')

                if os.path.isdir(files):
                    description = self._describe_runtime(path)
                    self.container_runtimes[path] = description

        self.window = Gtk.Window()
        self.window.set_default_size(600, 300)
        self.window.connect('delete-event', Gtk.main_quit)
        self.window.set_title('pressure-vessel options')

        self.grid = Gtk.Grid(
            row_spacing=6,
            column_spacing=6,
            margin_top=12,
            margin_bottom=12,
            margin_start=12,
            margin_end=12,
        )
        self.window.add(self.grid)

        row = 0

        label = Gtk.Label.new('')
        label.set_markup(
            'This is a test UI for developers. '
            '<b>'
            'Some options are known to break games and Steam features.'
            '</b>'
            ' Use at your own risk!'
        )
        label.set_line_wrap(True)
        self.grid.attach(label, 0, row, 2, 1)
        row += 1

        label = Gtk.Label.new('Container runtime')
        self.grid.attach(label, 0, row, 1, 1)

        self.container_runtime_combo = Gtk.ComboBoxText.new()
        self.container_runtime_combo.append(
            '/',
            'None (use host system and traditional LD_LIBRARY_PATH runtime)'
        )

        for path, description in sorted(self.container_runtimes.items()):
            self.container_runtime_combo.append(path, description)

        if self.container_runtimes:
            self.container_runtime_combo.set_active(1)
        else:
            self.container_runtime_combo.set_active(0)

        self.grid.attach(self.container_runtime_combo, 1, row, 1, 1)

        row += 1

        var_path = os.getenv('PRESSURE_VESSEL_COPY_RUNTIME_INTO', None)

        if var_path is None:
            value = False
            var_path = os.getenv(
                'PRESSURE_VESSEL_RUNTIME_BASE',
                os.path.dirname(__file__) + '/../..',
            ) + '/var'
            assert var_path is not None
        else:
            value = True

        self.var_path = os.path.realpath(var_path)
        self.copy_runtime_into_check = Gtk.CheckButton.new_with_label(
            'Create temporary runtime copy on disk'
        )
        self.copy_runtime_into_check.set_active(value)
        self.grid.attach(self.copy_runtime_into_check, 1, row, 1, 1)
        row += 1

        label = Gtk.Label.new('')
        label.set_markup(
            '<small><i>'
            'The copy will be in {} and can be modified while the '
            'container is running. It will be deleted next time you '
            'run a game in this mode.'
            '</i></small>'.format(
                GLib.markup_escape_text(self.var_path),
            )
        )
        label.set_halign(Gtk.Align.START)
        label.set_line_wrap(True)
        self.grid.attach(label, 1, row, 1, 1)
        row += 1

        self.host_graphics_check = Gtk.CheckButton.new_with_label(
            'Use host-system graphics stack'
        )
        value = boolean_environment('PRESSURE_VESSEL_HOST_GRAPHICS', True)
        self.host_graphics_check.set_active(value)
        self.grid.attach(self.host_graphics_check, 1, row, 1, 1)
        row += 1

        label = Gtk.Label.new('')
        label.set_markup(
            '<small><i>'
            "Most games and GPUs won't work when this is disabled."
            '</i></small>'
        )
        label.set_halign(Gtk.Align.START)
        label.set_line_wrap(True)
        self.grid.attach(label, 1, row, 1, 1)
        row += 1

        self._container_runtime_changed(self.container_runtime_combo)
        self.container_runtime_combo.connect(
            'changed',
            self._container_runtime_changed)

        self.unshare_home_check = Gtk.CheckButton.new_with_label(
            'Use a separate home directory per game'
        )
        share_home = tristate_environment('PRESSURE_VESSEL_SHARE_HOME')
        self.unshare_home_check.set_active(
            share_home is not None and not share_home
        )
        self.grid.attach(self.unshare_home_check, 1, row, 1, 1)
        row += 1

        label = Gtk.Label.new('')
        label.set_markup(
            '<small><i>'
            'Creating a separate home directory is experimental, '
            'and is likely to break Steam Cloud Auto-Sync, Steam Workshop '
            'and probably other features.'
            '</i></small>'
        )
        label.set_halign(Gtk.Align.START)
        label.set_line_wrap(True)
        self.grid.attach(label, 1, row, 1, 1)
        row += 1

        self.unshare_pid_check = Gtk.CheckButton.new_with_label(
            'Create a new process ID namespace'
        )
        share_pid = boolean_environment('PRESSURE_VESSEL_SHARE_PID', True)
        self.unshare_pid_check.set_active(not share_pid)
        self.grid.attach(self.unshare_pid_check, 1, row, 1, 1)
        row += 1

        label = Gtk.Label.new('')
        label.set_markup(
            '<small><i>'
            "Creating a new process ID namespace is very experimental, "
            "and is known to break Steam's tracking of running games."
            '</i></small>'
        )
        label.set_halign(Gtk.Align.START)
        label.set_line_wrap(True)
        self.grid.attach(label, 1, row, 1, 1)
        row += 1

        self.keep_game_overlay_check = Gtk.CheckButton.new_with_label(
            'Allow Steam Overlay'
        )
        remove = boolean_environment(
            'PRESSURE_VESSEL_REMOVE_GAME_OVERLAY', False
        )
        self.keep_game_overlay_check.set_active(not remove)
        self.grid.attach(self.keep_game_overlay_check, 1, row, 1, 1)
        row += 1

        label = Gtk.Label.new('')
        label.set_markup(
            '<small><i>'
            'Disabling this seems to work around some of the issues with '
            'process ID namespaces, but will break various Steam features.'
            '</i></small>'
        )
        label.set_halign(Gtk.Align.START)
        label.set_line_wrap(True)
        self.grid.attach(label, 1, row, 1, 1)
        row += 1

        self.xterm_check = Gtk.CheckButton.new_with_label('Run in an xterm')
        self.xterm_check.set_active(False)
        self.grid.attach(self.xterm_check, 1, row, 1, 1)

        env = os.getenv('PRESSURE_VESSEL_TERMINAL')

        if env is not None:
            if env == 'xterm':
                self.xterm_check.set_active(True)

        row += 1

        shell_label = Gtk.Label.new('Run an interactive shell')
        self.grid.attach(shell_label, 0, row, 1, 1)

        self.shell_combo = Gtk.ComboBoxText.new()
        self.shell_combo.append('', 'No')
        self.shell_combo.append('--shell-after', 'After running the command')
        self.shell_combo.append('--shell-fail', 'If the command fails')
        self.shell_combo.append(
            '--shell-instead', 'Instead of running the command')
        self.shell_combo.set_active(0)

        env = os.getenv('PRESSURE_VESSEL_SHELL')

        if env is not None:
            if env == 'after':
                self.shell_combo.set_active(1)
            elif env == 'fail':
                self.shell_combo.set_active(2)
            elif env == 'instead':
                self.shell_combo.set_active(3)

        self._shell_changed(self.shell_combo)
        self.shell_combo.connect('changed', self._shell_changed)
        self.grid.attach(self.shell_combo, 1, row, 1, 1)

        row += 1

        try:
            subproc = subprocess.Popen(
                [
                    os.path.join(
                        os.path.dirname(__file__),
                        'pressure-vessel-wrap'
                    ),
                    '--version-only',
                ],
                stdout=subprocess.PIPE,
            )
            stdout, _ = subproc.communicate()
            version = stdout.decode('utf-8', errors='replace')
        except Exception:
            version = ''

        if version:
            label = Gtk.Label.new('')
            label.set_markup(
                '<small>v{}</small>'.format(
                    GLib.markup_escape_text(version),
                )
            )
            label.set_line_wrap(True)
            label.set_halign(Gtk.Align.START)
            label.set_sensitive(False)
            self.grid.attach(label, 0, row, 1, 1)

        buttons_grid = Gtk.Grid(
            column_spacing=6,
            column_homogeneous=True,
            halign=Gtk.Align.END,
        )

        cancel_button = Gtk.Button.new_with_label('Cancel')
        cancel_button.connect('clicked', Gtk.main_quit)
        buttons_grid.attach(cancel_button, 0, 0, 1, 1)

        run_button = Gtk.Button.new_with_label('Run')
        run_button.connect('clicked', self.run_cb)
        buttons_grid.attach(run_button, 1, 0, 1, 1)

        self.grid.attach(buttons_grid, 1, row, 2, 1)

        row += 1

    def _shell_changed(self, shell_combo):
        if shell_combo.get_active_id():
            self.xterm_check.set_active(True)
            self.xterm_check.set_sensitive(False)
        else:
            self.xterm_check.set_sensitive(True)

    def _container_runtime_changed(self, combo):
        if combo.get_active_id() == '/':
            self.copy_runtime_into_check.set_sensitive(False)
            self.host_graphics_check.set_sensitive(False)
        else:
            self.copy_runtime_into_check.set_sensitive(True)
            self.host_graphics_check.set_sensitive(True)

    def _describe_runtime(
        self,
        path        # type: str
    ):
        # type: (...) -> str

        description = path
        files = os.path.join(path, 'files')
        metadata = os.path.join(path, 'metadata')

        if os.path.islink(files):
            description = os.path.realpath(files)

        if description.startswith(self.home + '/'):
            description = '~' + description[len(self.home):]

        name = None             # type: typing.Optional[str]
        pretty_name = None      # type: typing.Optional[str]
        build_id = None         # type: typing.Optional[str]
        variant = None          # type: typing.Optional[str]

        try:
            keyfile = GLib.KeyFile.new()
            keyfile.load_from_file(
                metadata, GLib.KeyFileFlags.NONE)
            try:
                build_id = keyfile.get_string('Runtime', 'x-flatdeb-build-id')
            except GLib.Error:
                pass

            try:
                name = keyfile.get_string('Runtime', 'runtime')
            except GLib.Error:
                pass
            else:
                assert name is not None
                variant = name.split('.')[-1]
        except GLib.Error:
            pass

        try:
            with open(
                os.path.join(files, 'lib', 'os-release')
            ) as reader:
                for line in reader:
                    if line.startswith('PRETTY_NAME='):
                        pretty_name = line.split('=', 1)[1].strip()
                        pretty_name = GLib.shell_unquote(pretty_name)
                    elif line.startswith('BUILD_ID='):
                        build_id = line.split('=', 1)[1].strip()
                        build_id = GLib.shell_unquote(build_id)
                    elif line.startswith('VARIANT='):
                        variant = line.split('=', 1)[1].strip()
                        variant = GLib.shell_unquote(variant)
        except (GLib.Error, EnvironmentError):
            pass

        if pretty_name is None:
            pretty_name = name

        if pretty_name is None:
            pretty_name = os.path.basename(path)

        if build_id is None:
            build_id = ''
        else:
            build_id = ' build {}'.format(build_id)

        if variant is None:
            variant = ''
        else:
            variant = ' {}'.format(variant)

        description = '{}{}{}\n({})'.format(
            pretty_name,
            variant,
            build_id,
            description,
        )

        return description

    def run_cb(self, _ignored=None):
        # type: (typing.Any) -> None

        argv = [
            os.path.join(
                os.path.dirname(__file__),
                'pressure-vessel-wrap'
            ),
        ]

        id = self.container_runtime_combo.get_active_id()

        if id is None:
            argv.append('--runtime=')
        elif id == '/':
            argv.append('--runtime=')
        else:
            argv.append('--runtime')
            argv.append(os.path.join(id, 'files'))

        if self.host_graphics_check.get_active():
            if os.path.isdir('/run/host'):
                argv.append('--graphics-provider=/run/host')
            else:
                argv.append('--graphics-provider=/')
        else:
            argv.append('--graphics-provider=')

        if self.copy_runtime_into_check.get_active():
            os.makedirs(self.var_path, mode=0o755, exist_ok=True)
            argv.append('--copy-runtime-into=' + self.var_path)
        else:
            argv.append('--copy-runtime-into=')

        if self.unshare_home_check.get_active():
            argv.append('--unshare-home')
        else:
            argv.append('--share-home')

        if self.unshare_pid_check.get_active():
            argv.append('--unshare-pid')
        else:
            argv.append('--share-pid')

        if self.keep_game_overlay_check.get_active():
            argv.append('--keep-game-overlay')
        else:
            argv.append('--remove-game-overlay')

        if self.xterm_check.get_active():
            argv.append('--terminal=xterm')
        else:
            argv.append('--terminal=none')

        id = self.shell_combo.get_active_id()

        if id is not None and id != '':
            argv.append(id)
        else:
            argv.append('--shell=none')

        argv.append('--verbose')
        argv.extend(sys.argv[1:])

        os.environ['G_MESSAGES_DEBUG'] = 'all'

        try:
            os.execvp(argv[0], argv)
        except OSError:
            logger.error('Unable to run: %s', ' '.join(map(shlex.quote, argv)))
            Gtk.main_quit()
            self.failed = True
            raise

    def run(self):
        # type: (...) -> None
        self.window.show_all()
        Gtk.main()

        if self.failed:
            sys.exit(1)


if __name__ == '__main__':
    if '--check-gui-dependencies' not in sys.argv:
        Gui().run()