Skip to content
Snippets Groups Projects
  • Simon McVittie's avatar
    feb948ed
    wrap: Add the ability to unshare the pid namespace · feb948ed
    Simon McVittie authored
    This improves isolation between the host system and the game, and can
    be used in conjunction with bwrap's init/reaper process to make game
    termination fully reliable (unlike subreapers, killing the init process
    automatically kills the entire pid namespace).
    
    One major down-side of doing this is that if the game uses
    process-ID-oriented APIs, for example older versions of
    <https://github.com/FeralInteractive/gamemode
    
    >, then they will not
    work, because the process ID inside the container is not the same as
    the process ID outside the container.
    
    Unfortunately, Steam's own tracking of the processes that belong to a game
    is one of the process-ID-oriented APIs that this harms, so this branch
    does not unshare the pid namespace by default, only when requested. We
    can use this to test whether it can be done without breaking Steam;
    We will probably need to cope with separate pid namespaces if we create
    new containers from inside a Flatpak environment.
    
    Also add an option to unload the gameoverlayrenderer.so module, which
    is not acceptable for production use, but at least works around this
    well enough to unblock further testing.
    
    Signed-off-by: default avatarSimon McVittie <smcv@collabora.com>
    feb948ed
    History
    wrap: Add the ability to unshare the pid namespace
    Simon McVittie authored
    This improves isolation between the host system and the game, and can
    be used in conjunction with bwrap's init/reaper process to make game
    termination fully reliable (unlike subreapers, killing the init process
    automatically kills the entire pid namespace).
    
    One major down-side of doing this is that if the game uses
    process-ID-oriented APIs, for example older versions of
    <https://github.com/FeralInteractive/gamemode
    
    >, then they will not
    work, because the process ID inside the container is not the same as
    the process ID outside the container.
    
    Unfortunately, Steam's own tracking of the processes that belong to a game
    is one of the process-ID-oriented APIs that this harms, so this branch
    does not unshare the pid namespace by default, only when requested. We
    can use this to test whether it can be done without breaking Steam;
    We will probably need to cope with separate pid namespaces if we create
    new containers from inside a Flatpak environment.
    
    Also add an option to unload the gameoverlayrenderer.so module, which
    is not acceptable for production use, but at least works around this
    well enough to unblock further testing.
    
    Signed-off-by: default avatarSimon McVittie <smcv@collabora.com>
pressure-vessel-test-ui 15.08 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 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.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(sys.argv[0]),
                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

        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

        subproc = subprocess.Popen(
            [
                os.path.join(
                    os.path.dirname(sys.argv[0]),
                    'pressure-vessel-wrap'
                ),
                '--version-only',
            ],
            stdout=subprocess.PIPE,
        )
        stdout, _ = subproc.communicate()
        version = stdout.decode('utf-8', errors='replace')

        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.host_graphics_check.set_sensitive(False)
        else:
            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 = [
            'env',
            'G_MESSAGES_DEBUG=all',
            os.path.join(
                os.path.dirname(sys.argv[0]),
                '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():
            argv.append('--with-host-graphics')
        else:
            argv.append('--without-host-graphics')

        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.execvp(argv[0], argv)

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


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