#!/usr/bin/env python3 # Copyright © 2019-2022 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 argparse import contextlib import logging import os import shlex import subprocess import sys try: import typing except ImportError: pass import gi gi.require_version('Gtk', '3.0') # Ignore E402: import not at top of file. gi.require_version() must come first from gi.repository import GLib # noqa: E402 from gi.repository import Gtk # noqa: E402 logger = logging.getLogger('steam-runtime-launch-options') assert sys.version_info >= (3, 4), 'Python 3.4+ is required for this script' # Linux runtime environments we can target with the Steam Runtime. # Traditionally, all native Linux games have run in a scout environment, # variously referred to as 'linux' or 'ubuntu12_32' by Steam. RUNTIMES = [ 'scout', # Steam Runtime 1 'scout', based on Ubuntu 12.04 'heavy', # Steam Runtime 1½ 'heavy', based on Debian 8 'soldier', # Steam Runtime 2 'soldier', based on Debian 10 'sniper', # Steam Runtime 3 'sniper', based on Debian 11 'medic', # Steam Runtime 4 'medic', provisionally based on Debian 12 'steamrt5', # Steam Runtime 5, provisionally based on Debian 13 ] # All available compatibility targets, including Windows (via Proton) # and native Linux (whatever the host system happens to be running, e.g. # SteamOS, Debian, Arch, Fedora). COMPAT_TARGETS = RUNTIMES + ['windows', 'host'] 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 def to_shell(argv): # type: (typing.Iterable[str]) -> str return ' '.join(map(shlex.quote, argv)) class Component: def __init__( self, path, # type: str home, # type: str ): # type: (...) -> None self.home = home self.path = path self.argv = [] # type: typing.List[str] self.description = '' self.runs_on = '' class App(Component): def __init__( self, path, # type: str home, # type: str argv, # type: typing.List[str] ): # type: (...) -> None super().__init__(path, home=home) self.argv = argv self.description = 'App or game to run' self.runs_on = 'scout' class Proton(Component): def __init__( self, path, # type: str home, # type: str argv, # type: typing.List[str] ): # type: (...) -> None super().__init__(path, home=home) self.argv = argv if path.startswith(self.home + '/'): path = '~' + path[len(self.home):] version = os.path.basename(self.path) self.description = '{}\n({})'.format(version or '(unknown)', path) # TODO: Parse this with python3-vdf? try: with open(os.path.join(self.path, 'toolmanifest.vdf')) as reader: content = reader.read().strip() except Exception: logger.debug('Failed to get Proton tool manifest', exc_info=True) content = '' if '1391110' in content: self.runs_on = 'soldier' elif '1628350' in content: self.runs_on = 'sniper' else: self.runs_on = 'scout' class PressureVessel(Component): def __init__( self, path, # type: str home, # type: str ): # type: (...) -> None super().__init__(path, home=home) if path.startswith(self.home + '/'): path = '~' + path[len(self.home):] try: subproc = subprocess.Popen( [ os.path.join( self.path, 'bin', 'pressure-vessel-wrap' ), '--version-only', ], stdout=subprocess.PIPE, ) stdout, _ = subproc.communicate() version = stdout.decode('utf-8', errors='replace').strip() except Exception: logger.debug( 'Failed to run %s/bin/pressure-vessel-wrap --version-only', self.path, exc_info=True, ) version = '' self.adverb = os.path.join( self.path, 'bin', 'pressure-vessel-adverb', ) self.description = '{}\n({})'.format(version or '(unknown)', path) self.unruntime = os.path.join( self.path, 'bin', 'pressure-vessel-unruntime', ) self.version = version class Runtime(Component): def __init__( self, path, # type: str home, # type: str ): # type: (...) -> None super().__init__(path, home=home) self.provides = '' class LdlpRuntime(Runtime): def __init__( self, path, # type: str home, # type: str ): # type: (...) -> None super().__init__(path, home=home) try: with open(os.path.join(path, 'version.txt')) as reader: version = reader.read().strip() except Exception: logger.debug('Failed to get LDLP runtime version', exc_info=True) version = '' if version.startswith('steam-runtime-heavy_'): version = 'heavy ' + version[len('steam-runtime-heavy_'):] if not self.provides: self.provides = 'heavy' elif version.startswith('steam-runtime_'): version = 'scout ' + version[len('steam-runtime_'):] if not self.provides: self.provides = 'scout' if path.startswith(self.home + '/'): path = '~' + path[len(self.home):] self.description = '{}\n({})'.format( version or '(unknown version)', path, ) self.argv = [ os.path.join(self.path, 'scripts', 'switch-runtime.sh'), '--runtime=' + self.path, '--', ] class LaunchWrapper(Component): def __init__( self, path, # type: str home, # type: str argv, # type: typing.List[str] ): # type: (...) -> None super().__init__(path, home=home) self.argv = argv class Reaper(Component): def __init__( self, path, # type: str home, # type: str argv, # type: typing.List[str] ): # type: (...) -> None super().__init__(path, home=home) self.argv = argv class LayeredRuntime(LdlpRuntime): def __init__( self, path, # type: str home, # type: str argv, # type: typing.List[str] ): # type: (...) -> None super().__init__(path, home=home) self.argv = argv if path.startswith(self.home + '/'): path = '~' + path[len(self.home):] self.description = path self.provides = 'scout' self.runs_on = 'soldier' class ContainerRuntime(Runtime): def get_sort_weight( self, default='' # type: str ): # type: (...) -> typing.Any return (0,) def _runtime_version(self): # type: (...) -> typing.Any if self.provides.startswith('steamrt'): return int(self.provides[len('steamrt'):]) return { 'scout': 1, 'heavy': 1.5, 'soldier': 2, 'sniper': 3, 'medic': 4, }.get(self.provides, 0) class ContainerRuntimeDepot(ContainerRuntime): def __init__( self, path, # type: str home, # type: str argv, # type: typing.List[str] ): # type: (...) -> None super().__init__(path, home=home) self.argv = argv if path.startswith(self.home + '/'): path = '~' + path[len(self.home):] try: self.description = self.__describe_runtime(path) except Exception: logger.debug('Failed to get runtime info', exc_info=True) self.description = os.path.basename(path) self.description = '{}\n({})'.format(self.description, path) self.pressure_vessel = None # type: typing.Optional[PressureVessel] self.var_path = os.path.join(self.path, 'var') try: pv = PressureVessel( os.path.join(self.path, 'pressure-vessel'), home=home, ) except Exception: logger.debug('Failed to get PV info', exc_info=True) else: self.pressure_vessel = pv def get_sort_weight(self, default): if default == self.provides: weight = -10 else: weight = -1 return (weight, self._runtime_version(), self.path) def __describe_runtime( self, path # type: str ): # type: (...) -> str platform = ['', ''] depot_version = '' with open(os.path.join(self.path, 'VERSIONS.txt')) as reader: for row in reader: if row.startswith('#'): continue if row.startswith('depot\t'): depot_version = row.split('\t')[1] if row.startswith(('soldier\t', 'sniper\t')): platform = row.split('\t')[:1] if platform[0]: self.provides = platform[0] return platform[0] + ' ' + (depot_version or platform[1]) return '(unknown)' class DirectoryRuntime(ContainerRuntime): def __init__( self, path, # type: str home, # type: str ): # type: (...) -> None super().__init__(path, home=home) self.description = self.__describe_runtime(path) def get_sort_weight(self, default): return (1, self._runtime_version(), self.path) def __describe_runtime( self, path # type: str ): # type: (...) -> str description = path files = os.path.join(self.path, 'files') metadata = os.path.join(self.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 class ArchiveRuntime(ContainerRuntime): def __init__( self, path, # type: str buildid_file, # type: str home, # type: str ): # type: (...) -> None super().__init__(path, home=home) if path.startswith(self.home + '/'): path = '~' + path[len(self.home):] description = os.path.basename(path) sdk_suffix = '' if description.startswith('com.valvesoftware.SteamRuntime.'): description = description[len('com.valvesoftware.SteamRuntime.'):] if description.startswith('Platform-'): description = description[len('Platform-'):] if description.startswith('Sdk-'): sdk_suffix = '-sdk' description = description[len('Sdk-'):] if description.startswith('amd64,i386-'): description = description[len('amd64,i386-'):] if description.endswith('.tar.gz'): description = description[:-len('.tar.gz')] if description.endswith('-runtime'): description = description[:-len('-runtime')] with open(buildid_file) as reader: build = reader.read().strip() self.deploy_id = '{}{}_{}'.format(description, sdk_suffix, build) self.description = '{} build {}\n({})'.format(description, build, path) def get_sort_weight(self, default): return (2, self._runtime_version(), self.path) class Gui: def __init__(self): # type: (...) -> None self.steam_runtime_env = {} # type: typing.Dict[str, str] self.failed = False self.home = GLib.get_home_dir() self.app = App(path='', argv=[], home=self.home) self.launch_wrapper = None # type: typing.Optional[LaunchWrapper] self.reaper = None # type: typing.Optional[Reaper] self.default_container_runtime = ( None ) # type: typing.Optional[ContainerRuntimeDepot] self.default_pressure_vessel = ( None ) # type: typing.Optional[PressureVessel] self.default_layered_runtime = ( None ) # type: typing.Optional[LayeredRuntime] self.default_proton = ( None ) # type: typing.Optional[Proton] self.container_runtimes = { } # type: typing.Dict[str, ContainerRuntime] self.pressure_vessels = { } # type: typing.Dict[str, PressureVessel] self.layered_runtimes = { } # type: typing.Dict[str, LayeredRuntime] self.ldlp_runtimes = { } # type: typing.Dict[str, LdlpRuntime] self.proton_versions = { } # type: typing.Dict[str, Proton] self._changing = 0 self._changing_container_runtime = 0 self._container_runtime_changed_id = 0 self._layered_runtime_changed_id = 0 self._pressure_vessel_changed_id = 0 self._ldlp_runtime_changed_id = 0 self._proton_changed_id = 0 self.window = Gtk.Window() self.window.set_default_size(720, 480) self.window.connect('delete-event', Gtk.main_quit) self.window.set_title('Launch options') self.vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 6) self.window.add(self.vbox) row = 0 self.grid = Gtk.Grid( row_spacing=6, column_spacing=6, margin_top=12, margin_bottom=12, margin_start=12, margin_end=12, ) scrolled_window = Gtk.ScrolledWindow.new(None, None) scrolled_window.add(self.grid) if hasattr(scrolled_window.props, 'propagate_natural_width'): scrolled_window.props.propagate_natural_width = True scrolled_window.props.propagate_natural_height = True scrolled_window.set_policy( Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC, ) self.vbox.pack_start(scrolled_window, True, True, 0) label = Gtk.Label.new('') label.set_markup( 'This is a test UI for developers. ' '' 'Some options are known to break games and Steam features.' '' ' Use at your own risk!' ) label.set_line_wrap(True) self.grid.attach(label, 0, row, 3, 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.grid.attach(self.container_runtime_combo, 1, row, 2, 1) row += 1 label = Gtk.Label.new('Variable data path') self.grid.attach(label, 0, row, 1, 1) self.var_path_entry = Gtk.Entry.new() self.var_path_entry.props.editable = False self.var_path_entry.props.has_frame = False self.var_path_entry.props.hexpand = True self.grid.attach(self.var_path_entry, 1, row, 1, 1) self.var_path_browse = Gtk.Button.new_with_label('Browse...') self.var_path_browse.connect('clicked', self.var_path_browse_cb) self.var_path_browse.props.hexpand = False self.grid.attach(self.var_path_browse, 2, row, 1, 1) row += 1 label = Gtk.Label.new('pressure-vessel') self.grid.attach(label, 0, row, 1, 1) self.pressure_vessel_combo = Gtk.ComboBoxText.new() self.grid.attach(self.pressure_vessel_combo, 1, row, 2, 1) row += 1 label = Gtk.Label.new('Layered runtime scripts') self.grid.attach(label, 0, row, 1, 1) self.layered_runtime_combo = Gtk.ComboBoxText.new() self.grid.attach(self.layered_runtime_combo, 1, row, 2, 1) row += 1 label = Gtk.Label.new('LD_LIBRARY_PATH runtime') self.grid.attach(label, 0, row, 1, 1) self.ldlp_runtime_combo = Gtk.ComboBoxText.new() self.grid.attach(self.ldlp_runtime_combo, 1, row, 2, 1) row += 1 label = Gtk.Label.new('Proton') self.grid.attach(label, 0, row, 1, 1) self.proton_combo = Gtk.ComboBoxText.new() self.grid.attach(self.proton_combo, 1, row, 2, 1) row += 1 label = Gtk.Label.new('SDL video driver') self.grid.attach(label, 0, row, 1, 1) self.sdl_videodriver_combo = Gtk.ComboBoxText.new() self.sdl_videodriver_combo.append(None, "Don't override") self.sdl_videodriver_combo.append('wayland', 'Wayland') self.sdl_videodriver_combo.append('x11', 'X11') self.sdl_videodriver_combo.set_active(0) self.grid.attach(self.sdl_videodriver_combo, 1, row, 2, 1) row += 1 label = Gtk.Label.new('Graphics stack') self.grid.attach(label, 0, row, 1, 1) self.graphics_provider_combo = Gtk.ComboBoxText.new() self.graphics_provider_combo.append(None, "Don't override") env = os.getenv('PRESSURE_VESSEL_GRAPHICS_PROVIDER') if env is not None: self.graphics_provider_combo.append( env, '$PRESSURE_VESSEL_GRAPHICS_PROVIDER ({})'.format( env or 'empty' ), ) if env is None or env != '/': self.graphics_provider_combo.append( '/', 'Current execution environment', ) if ( (env is None or env != '/run/host') and os.path.isdir('/run/host/etc') and os.path.isdir('/run/host/usr') ): self.graphics_provider_combo.append( '/run/host', 'Host system', ) if env is None or env != '': self.graphics_provider_combo.append( '', "Container's own libraries (probably won't work)", ) self.graphics_provider_combo.set_active(0) self.graphics_provider_combo.connect( 'changed', self._something_changed_cb, ) self.grid.attach(self.graphics_provider_combo, 1, row, 2, 1) row += 1 label = Gtk.Label.new('Home directory') self.grid.attach(label, 0, row, 1, 1) self.share_home_combo = Gtk.ComboBoxText.new() self.share_home_combo.append(None, "Don't override") self.share_home_combo.append('1', 'Shared between all games') self.share_home_combo.append( '0', ('Separate per game ' '(experimental, breaks Steam features)'), ) self.share_home_combo.set_active(0) self.share_home_combo.connect( 'changed', self._something_changed_cb, ) self.grid.attach(self.share_home_combo, 1, row, 2, 1) row += 1 label = Gtk.Label.new('Process ID namespace') self.grid.attach(label, 0, row, 1, 1) self.share_pid_combo = Gtk.ComboBoxText.new() self.share_pid_combo.append(None, "Don't override") self.share_pid_combo.append( '1', 'Use the same process ID namespace as Steam', ) self.share_pid_combo.append( '0', ('Create a new process ID namespace ' '(experimental, breaks Steam features)'), ) self.share_pid_combo.set_active(0) self.share_pid_combo.connect( 'changed', self._something_changed_cb, ) self.grid.attach(self.share_pid_combo, 1, row, 2, 1) row += 1 label = Gtk.Label.new('Steam Overlay') self.grid.attach(label, 0, row, 1, 1) self.remove_game_overlay_combo = Gtk.ComboBoxText.new() self.remove_game_overlay_combo.append(None, "Don't override") self.remove_game_overlay_combo.append('0', 'Keep Steam Overlay') self.remove_game_overlay_combo.append( '1', 'Remove Steam Overlay (breaks Steam features)', ) self.remove_game_overlay_combo.set_active(0) self.remove_game_overlay_combo.connect( 'changed', self._something_changed_cb, ) self.grid.attach(self.remove_game_overlay_combo, 1, row, 2, 1) row += 1 label = Gtk.Label.new('Vulkan layers') self.grid.attach(label, 0, row, 1, 1) self.vulkan_layers_combo = Gtk.ComboBoxText.new() self.vulkan_layers_combo.append(None, "Don't override") self.vulkan_layers_combo.append( '1', 'Force importing Vulkan layers from host', ) self.vulkan_layers_combo.append( '0', 'Disable Vulkan layers from host', ) self.vulkan_layers_combo.set_active(0) self.vulkan_layers_combo.connect( 'changed', self._something_changed_cb, ) self.grid.attach(self.vulkan_layers_combo, 1, row, 2, 1) row += 1 label = Gtk.Label.new('Command injection') self.grid.attach(label, 0, row, 1, 1) self.launcher_service_combo = Gtk.ComboBoxText.new() self.launcher_service_combo.append(None, "Don't override") self.launcher_service_combo.append( 'container-runtime', 'SteamLinuxRuntime_{soldier,sniper,...}', ) self.launcher_service_combo.append( 'proton', 'any Proton version', ) self.launcher_service_combo.append( 'scout-in-container', 'any layered scout-on-* runtime', ) self.launcher_service_combo.append( '', 'None', ) self.launcher_service_combo.set_active(0) self.launcher_service_combo.connect( 'changed', self._something_changed_cb, ) self.grid.attach(self.launcher_service_combo, 1, row, 2, 1) row += 1 label = Gtk.Label.new('Interactive terminal') self.grid.attach(label, 0, row, 1, 1) self.terminal_combo = Gtk.ComboBoxText.new() self.terminal_combo.append(None, "Don't override") self.terminal_combo.append('xterm', 'Run in an xterm') self.terminal_combo.append( 'none', "Don't run in an interactive terminal", ) self.terminal_combo.set_active(0) self.terminal_combo.connect( 'changed', self._something_changed_cb, ) self.grid.attach(self.terminal_combo, 1, row, 2, 1) row += 1 label = Gtk.Label.new('Interactive shell') self.grid.attach(label, 0, row, 1, 1) self.shell_combo = Gtk.ComboBoxText.new() self.shell_combo.append(None, "Don't override") self.shell_combo.append('none', 'No, just run the command') self.shell_combo.append('after', 'After running the command') self.shell_combo.append('fail', 'If the command fails') self.shell_combo.append('instead', 'Instead of running the command') self.shell_combo.set_active(0) self.shell_combo.connect( 'changed', self._something_changed_cb, ) self.grid.attach(self.shell_combo, 1, row, 2, 1) row += 1 self.debug_check = Gtk.CheckButton.new_with_label( 'Extra debug logging', ) self.debug_check.set_active(False) self.grid.attach(self.debug_check, 1, row, 2, 1) row += 1 label = Gtk.Label.new('Command to run') self.grid.attach(label, 0, row, 1, 1) self.command_entry = Gtk.Entry.new() self.command_entry.props.editable = True self.command_entry.set_text(to_shell(self.app.argv)) self.command_entry.connect( 'notify::text', self._command_entry_changed_cb, ) self.grid.attach(self.command_entry, 1, row, 2, 1) row += 1 label = Gtk.Label.new('Preview of final command') self.grid.attach(label, 0, row, 1, 1) self.final_command_view = Gtk.TextView.new() self.final_command_view.props.editable = False scrolled_window = Gtk.ScrolledWindow.new(None, None) scrolled_window.add(self.final_command_view) if hasattr(scrolled_window.props, 'propagate_natural_width'): scrolled_window.props.propagate_natural_width = True scrolled_window.props.propagate_natural_height = True else: scrolled_window.props.height_request = 120 self.grid.attach(scrolled_window, 1, row, 2, 1) row += 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.vbox.pack_end(buttons_grid, False, False, 0) self._container_runtime_changed_id = ( self.container_runtime_combo.connect( 'changed', self._container_runtime_changed, ) ) self._pressure_vessel_changed_id = ( self.pressure_vessel_combo.connect( 'changed', self._pressure_vessel_changed, ) ) self._layered_runtime_changed_id = ( self.layered_runtime_combo.connect( 'changed', self._layered_runtime_changed, ) ) self._ldlp_runtime_changed_id = ( self.ldlp_runtime_combo.connect( 'changed', self._ldlp_runtime_changed, ) ) self._proton_changed_id = ( self.proton_combo.connect( 'changed', self._proton_changed, ) ) def parse_args( self, argv # type: typing.List[str] ): # type: (...) -> None parser = argparse.ArgumentParser() parser.add_argument( '--compatible-with', choices=COMPAT_TARGETS + ['auto', 'any'], default='auto', ) parser.add_argument( '--steam-runtime-env', action='append', default=[], ) parser.add_argument('--verbose', action='store_true') parser.add_argument('command', nargs='+') args = parser.parse_args() for token in args.steam_runtime_env: if '=' not in token: parser.error('--steam-runtime-env requires VAR=VALUE argument') var, value = token.split('=', 1) self.steam_runtime_env[var] = value command_argv = args.command assert len(command_argv) >= 1 self.app.runs_on = args.compatible_with if args.verbose: logging.getLogger().setLevel(logging.DEBUG) if ( len(command_argv) > 2 and command_argv[0].endswith('ubuntu12_32/reaper') and '--' in command_argv[:-1] ): reaper_args = [] # type: typing.List[str] while len(command_argv) > 0: reaper_args.append(command_argv[0]) command_argv = command_argv[1:] if reaper_args[-1] == '--': break logger.debug('Detected reaper: %s', to_shell(reaper_args)) logger.debug( 'Remaining arguments: %s', to_shell(command_argv), ) self.reaper = Reaper( path=command_argv[0], home=self.home, argv=reaper_args, ) if ( len(command_argv) > 2 and command_argv[0].endswith('ubuntu12_32/steam-launch-wrapper') and '--' in command_argv[:-1] ): wrapper_args = [] # type: typing.List[str] while len(command_argv) > 0: wrapper_args.append(command_argv[0]) command_argv = command_argv[1:] if wrapper_args[-1] == '--': break logger.debug('Detected launch wrapper %s', to_shell(wrapper_args)) logger.debug( 'Remaining arguments: %s', to_shell(command_argv), ) self.launch_wrapper = LaunchWrapper( path=command_argv[0], home=self.home, argv=wrapper_args, ) for target in RUNTIMES: if ( len(command_argv) > 2 and command_argv[0].endswith(( '/SteamLinuxRuntime_%s/run' % target, '/SteamLinuxRuntime_%s/run-in-%s' % (target, target), '/SteamLinuxRuntime_%s/_v2-entry-point' % target, )) and '--' in command_argv[:-1] ): if args.compatible_with == 'auto': self.app.runs_on = target runtime_args = [] # type: typing.List[str] while len(command_argv) > 0: runtime_args.append(command_argv[0]) command_argv = command_argv[1:] if runtime_args[-1] == '--': break runtime = ContainerRuntimeDepot( path=os.path.dirname(runtime_args[0]), home=self.home, argv=runtime_args, ) runtime.provides = target self.default_container_runtime = runtime self.default_pressure_vessel = runtime.pressure_vessel logger.debug('Detected SLR: %s', to_shell(runtime_args)) logger.debug( 'Remaining arguments: %s', to_shell(command_argv), ) if ( len(command_argv) > 2 and command_argv[0].endswith( '/scout-on-soldier-entry-point-v2' ) and '--' in command_argv[:-1] ): if args.compatible_with == 'auto': self.app.runs_on = 'scout' runtime_args = [] while len(command_argv) > 0: runtime_args.append(command_argv[0]) command_argv = command_argv[1:] if runtime_args[-1] == '--': break self.default_layered_runtime = LayeredRuntime( path=os.path.dirname(runtime_args[0]), home=self.home, argv=runtime_args, ) logger.debug('Detected layered SLR: %s', to_shell(runtime_args)) logger.debug( 'Remaining arguments: %s', to_shell(command_argv), ) if ( len(command_argv) > 2 and command_argv[0].endswith('/proton') and command_argv[1] in ('run', 'waitforexitandrun') ): if args.compatible_with == 'auto': self.app.runs_on = 'windows' runtime_args = command_argv[:2] command_argv = command_argv[2:] if command_argv[0] == '--': runtime_args.append('--') command_argv = command_argv[1:] self.default_proton = Proton( path=os.path.dirname(runtime_args[0]), home=self.home, argv=runtime_args, ) logger.debug('Detected Proton: %s', to_shell(runtime_args)) logger.debug( 'Remaining arguments: %s', to_shell(command_argv), ) if 'PRESSURE_VESSEL_PREFIX' in os.environ: self.default_pressure_vessel = PressureVessel( os.environ['PRESSURE_VESSEL_PREFIX'], self.home, ) if self.app.runs_on == 'auto': self.app.runs_on = 'scout' logger.debug('Assuming final app runs on: %s', self.app.runs_on) self.app.argv = command_argv self.command_entry.set_text(to_shell(command_argv)) self.refresh_runtimes() def var_path_browse_cb(self, button): # type: (typing.Any) -> None dialog = Gtk.FileChooserDialog( title='Choose variable data directory', parent=self.window, action=Gtk.FileChooserAction.SELECT_FOLDER, buttons=("Open", Gtk.ResponseType.ACCEPT), ) if dialog.run() == Gtk.ResponseType.ACCEPT: self.var_path_entry.set_text(dialog.get_filename()) dialog.destroy() def _search( self, source_of_runtimes, # type: str seen, # type: typing.Set[str] in_runtime=False # type: bool ): # type: (...) -> None if not os.path.isdir(source_of_runtimes): return try: source_of_runtimes = os.path.realpath(source_of_runtimes) except OSError: return if source_of_runtimes in seen: return seen.add(source_of_runtimes) for member in os.listdir(source_of_runtimes): path = os.path.realpath( os.path.join(source_of_runtimes, member) ) if member.startswith('SteamLinuxRuntime') and not in_runtime: if ( os.path.isdir(path) and os.path.exists(os.path.join(path, 'run')) and path not in self.container_runtimes ): # Note that SteamLinuxRuntime (1070560) also has a # _v2-entry-point, so we can't use that to detect # complete container runtimes; but we should prefer # to use it to run container runtimes, to be more like # what Steam would do in the absence of this script. exe = os.path.join(path, '_v2-entry-point') if not os.path.exists(exe): exe = os.path.join(path, 'run') container_runtime = ContainerRuntimeDepot( path=path, home=self.home, argv=[exe, '--'], ) logger.debug( 'Discovered container runtime depot: %s', path, ) logger.debug( 'Arguments: %s', to_shell(container_runtime.argv), ) self.container_runtimes[path] = container_runtime pv = container_runtime.pressure_vessel if pv is not None: logger.debug( 'Discovered pressure-vessel in %s: %s', path, pv.path, ) if pv.path not in self.pressure_vessels: self.pressure_vessels[pv.path] = pv self._search(path, seen, in_runtime=True) elif ( os.path.isdir(path) and os.path.exists( os.path.join(path, 'scout-on-soldier-entry-point-v2') ) and path not in self.layered_runtimes ): layered_runtime = LayeredRuntime( path=path, home=self.home, argv=[ os.path.join( path, 'scout-on-soldier-entry-point-v2', ), '--', ], ) logger.debug( 'Discovered layered runtime depot: %s', path, ) logger.debug( 'Arguments: %s', to_shell(layered_runtime.argv), ) self.layered_runtimes[path] = layered_runtime continue if member.startswith('Proton ') and not in_runtime: if ( os.path.isdir(path) and os.path.exists(os.path.join(path, 'proton')) and path not in self.proton_versions ): proton = Proton( path=path, home=self.home, argv=[ os.path.join(path, 'proton'), 'waitforexitandrun', ], ) logger.debug( 'Discovered Proton: %s', path, ) logger.debug( 'Arguments: %s', to_shell(proton.argv), ) self.proton_versions[path] = proton continue metadata = os.path.join(path, 'metadata') files = os.path.join(path, 'files') if os.path.isdir(files) and os.path.isfile(metadata): logger.debug( 'Discovered possible runtime directory: %s', path, ) if path not in self.container_runtimes: self.container_runtimes[path] = DirectoryRuntime( path, home=self.home, ) continue if member.endswith(('-runtime.tar.gz', '-sysroot.tar.gz')): # runtime and sysroot happen to be the same length! buildid_file = os.path.join( source_of_runtimes, member[:-len('-runtime.tar.gz')] + '-buildid.txt', ) if os.path.exists(buildid_file): logger.debug( 'Discovered possible archive runtime: %s', path, ) if path not in self.container_runtimes: self.container_runtimes[path] = ArchiveRuntime( path, buildid_file=buildid_file, home=self.home, ) continue if member in ('steam-runtime', 'steam-runtime-heavy'): logger.debug( 'Discovered possible LD_LIBRARY_PATH runtime: %s', path, ) if path not in self.container_runtimes: self.ldlp_runtimes[path] = LdlpRuntime( path, home=self.home, ) if ( member == 'pressure-vessel' and os.path.exists( os.path.join( path, 'bin', 'pressure-vessel-wrap', ) ) and path not in self.pressure_vessels ): logger.debug( 'Discovered additional pressure-vessel version: %s', path, ) self.pressure_vessels[path] = PressureVessel( path, self.home, ) @contextlib.contextmanager def _pause_changes(self): # type: (...) -> typing.Generator[Gui, None, None] try: self._changing += 1 yield self finally: self._changing -= 1 if self._changing: return if self._changing_container_runtime: self._changing_container_runtime = False widgets = [ self.graphics_provider_combo, self.layered_runtime_combo, self.remove_game_overlay_combo, self.share_home_combo, self.share_pid_combo, self.vulkan_layers_combo, ] if ( self.default_pressure_vessel is None and not self.pressure_vessels ): widgets.extend([ self.shell_combo, self.terminal_combo, ]) if self.container_runtime_combo.get_active_id() == '/': logger.debug('Selected absence of container runtime') for widget in widgets: widget.set_sensitive(False) else: rt = self.container_runtimes.get( self.container_runtime_combo.get_active_id() ) assert rt logger.debug( 'Selected container runtime: %s %s (%r)', rt.__class__.__name__, rt.path, rt.argv, ) selected = self.pressure_vessel_combo.get_active_id() assert selected is not None assert selected assert selected != '/' if isinstance(rt, ContainerRuntimeDepot): its_pv = rt.pressure_vessel if its_pv is not None and selected != its_pv.path: self.pressure_vessel_combo.set_active_id(its_pv.path) self.var_path_entry.set_text(rt.var_path) else: # keep the previous self.var_path_entry, it's better # than nothing... pass for widget in widgets: widget.set_sensitive(True) # If soldier or sniper was selected, try to use a layered # runtime to provide a scout-compatible ABI. logger.debug( 'App runs on %s, runtime provides %s', self.app.runs_on, rt.provides, ) if self.app.runs_on == 'scout' and rt.provides != 'scout': layered_runtime = self.default_layered_runtime if ( layered_runtime is not None and layered_runtime.provides == 'scout' ): logger.debug( 'Using layered runtime %s', layered_runtime.path, ) self.layered_runtime_combo.set_active_id( layered_runtime.path, ) self.build_argv() def refresh_runtimes(self): # type: (...) -> None with self._pause_changes(): selected_container = self.container_runtime_combo.get_active_id() self.container_runtime_combo.remove_all() self.container_runtimes = {} container_runtime = self.default_container_runtime if container_runtime is None: self.container_runtime_combo.append('/', 'None') else: path = container_runtime.path self.container_runtimes[path] = container_runtime self.container_runtime_combo.append( path, container_runtime.description, ) selected_layered = self.layered_runtime_combo.get_active_id() self.layered_runtime_combo.remove_all() self.layered_runtimes = {} layered_runtime = self.default_layered_runtime if layered_runtime is None: self.layered_runtime_combo.append('/', 'None') else: self.layered_runtimes[layered_runtime.path] = layered_runtime self.layered_runtime_combo.append( layered_runtime.path, layered_runtime.description, ) selected_ldlp = self.ldlp_runtime_combo.get_active_id() self.ldlp_runtime_combo.remove_all() self.ldlp_runtime_combo.append(None, "Don't override") selected_pv = self.pressure_vessel_combo.get_active_id() self.pressure_vessel_combo.remove_all() self.pressure_vessels = {} pressure_vessel = self.default_pressure_vessel if pressure_vessel is not None: self.pressure_vessels[pressure_vessel.path] = pressure_vessel self.pressure_vessel_combo.append( pressure_vessel.path, pressure_vessel.description, ) selected_proton = self.proton_combo.get_active_id() self.proton_combo.remove_all() self.proton_versions = {} proton = self.default_proton if proton is None: self.proton_combo.append('/', 'None') else: self.proton_versions[proton.path] = proton self.proton_combo.append( proton.path, proton.description, ) # Search for SteamLinuxRuntime, etc. in plausible Steam libraries search_path = [] # type: typing.List[typing.Optional[str]] seen = set() # type: typing.Set[str] search_path.append(os.path.expanduser('~/.steam/root/ubuntu12_32')) search_path.append(os.path.expanduser('~/.steam/root/ubuntu12_64')) for path in os.getenv('STEAM_COMPAT_LIBRARY_PATHS', '').split(':'): if path: search_path.append( os.path.join(path, 'steamapps', 'common'), ) search_path.append( os.path.expanduser('~/.steam/steam/steamapps/common') ) if 'XDG_DATA_HOME' in os.environ: search_path.append( os.path.expanduser('$XDG_DATA_HOME/Steam/steamapps/common') ) search_path.append( os.path.expanduser('~/.local/share/Steam/steamapps/common') ) search_path.append( os.path.expanduser('~/SteamLibrary/steamapps/common') ) search_path.append(os.path.expanduser('~/tmp')) search_path.append('.') search_path.append(os.getenv('PRESSURE_VESSEL_RUNTIME_BASE')) for search in search_path: 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 self._search(source_of_runtimes, seen) already_had_default = (self.default_layered_runtime is not None) # Do these first, because they influence what's listed in the # container runtime chooser for path, layered_runtime in sorted(self.layered_runtimes.items()): assert layered_runtime is not None if layered_runtime != self.default_layered_runtime: self.layered_runtime_combo.append( path, layered_runtime.description, ) if self.default_layered_runtime is None: self.default_layered_runtime = layered_runtime if already_had_default: self.layered_runtime_combo.append('/', 'None') if self.app.runs_on in RUNTIMES: list_first = self.app.runs_on elif self.default_container_runtime is not None: list_first = self.default_container_runtime.provides else: list_first = '' for path, runtime in sorted( self.container_runtimes.items(), key=lambda pair: pair[1].get_sort_weight(list_first), ): assert runtime is not None if ( runtime != self.default_container_runtime and ( self.app.runs_on not in RUNTIMES or not runtime.provides or self.app.runs_on == runtime.provides or ( self.app.runs_on == 'scout' and self.default_layered_runtime is not None ) ) ): self.container_runtime_combo.append( path, runtime.description, ) for path, pressure_vessel in sorted(self.pressure_vessels.items()): assert pressure_vessel is not None if pressure_vessel != self.default_pressure_vessel: self.pressure_vessel_combo.append( path, pressure_vessel.description, ) for path, ldlp_runtime in sorted(self.ldlp_runtimes.items()): assert ldlp_runtime is not None self.ldlp_runtime_combo.append(path, ldlp_runtime.description) for path, proton in sorted(self.proton_versions.items()): assert proton is not None if ( proton != self.default_proton and self.app.runs_on == 'windows' ): self.proton_combo.append(path, proton.description) if self.default_container_runtime is not None: self.container_runtime_combo.append('/', 'None') # There is no "none" option for pressure-vessel self.ldlp_runtime_combo.append('/', 'None') if ( self.default_proton is not None and self.app.runs_on != 'windows' ): self.proton_combo.append('/', 'None') if ( selected_container is not None and self.container_runtime_combo.set_active_id( selected_container, ) ): pass else: self.container_runtime_combo.set_active(0) if ( selected_pv is not None and self.pressure_vessel_combo.set_active_id(selected_pv) ): pass else: self.pressure_vessel_combo.set_active(0) if ( selected_layered is not None and self.layered_runtime_combo.set_active_id(selected_layered) ): pass else: self.layered_runtime_combo.set_active(0) if ( selected_ldlp is not None and self.ldlp_runtime_combo.set_active_id(selected_ldlp) ): pass else: self.ldlp_runtime_combo.set_active(0) if ( selected_proton is not None and self.proton_combo.set_active_id(selected_proton) ): pass else: self.proton_combo.set_active(0) self._container_runtime_changed(self.container_runtime_combo) def _container_runtime_changed(self, combo): # type: (typing.Any) -> None with self._pause_changes(): logger.debug( 'Selected container runtime: %s', combo.get_active_id(), ) self._changing_container_runtime = True def _pressure_vessel_changed(self, combo): # type: (typing.Any) -> None with self._pause_changes(): logger.debug( 'Selected pressure-vessel: %s', combo.get_active_id(), ) def _layered_runtime_changed(self, combo): # type: (typing.Any) -> None with self._pause_changes(): container = self.container_runtime_combo.get_active_id() if combo.get_active_id() == '/': logger.debug('Selected absence of layered runtime') if container and container != '/': self.ldlp_runtime_combo.set_sensitive(False) else: self.ldlp_runtime_combo.set_sensitive(True) else: rt = self.layered_runtimes.get(combo.get_active_id()) assert rt logger.debug( 'Selected layered runtime: %s %s (%r)', rt.__class__.__name__, rt.path, rt.argv, ) if container and container != '/': self.ldlp_runtime_combo.set_sensitive(True) else: self.ldlp_runtime_combo.set_sensitive(False) def _ldlp_runtime_changed(self, combo): # type: (typing.Any) -> None with self._pause_changes(): if combo.get_active_id() == '/': logger.debug('Selected absence of LD_LIBRARY_PATH runtime') elif combo.get_active_id() is None: logger.debug( 'Selected automatic choice of LD_LIBRARY_PATH runtime', ) else: rt = self.ldlp_runtimes.get(combo.get_active_id()) assert rt logger.debug( 'Selected LD_LIBRARY_PATH runtime: %s %s (%r)', rt.__class__.__name__, rt.path, rt.argv, ) def _proton_changed(self, combo): # type: (typing.Any) -> None with self._pause_changes(): logger.debug( 'Selected Proton: %s', combo.get_active_id(), ) def _something_changed_cb(self, sender='Something', *args, **kwargs): # type: (...) -> None with self._pause_changes(): logger.debug('%s changed', sender) def _command_entry_changed_cb(self, entry, param_spec): # type: (typing.Any, typing.Any) -> None with self._pause_changes(): logger.debug('Command to run changed to: %s', entry.props.text) argv = shlex.split(entry.props.text) logger.debug('Command parsed to %r', argv) self.app.argv = argv def run_cb(self, _ignored=None): # type: (typing.Any) -> None argv, environ = self.build_argv() try: os.execvpe(argv[0], argv, environ) except OSError: logger.error('Unable to run: %s', to_shell(argv)) Gtk.main_quit() self.failed = True raise def build_argv(self): # type: (...) -> typing.Tuple[typing.List[str], typing.Dict[str, str]] lines = [] # type: typing.List[str] argv = [] # type: typing.List[str] environ = {} # type: typing.Dict[str, str] components = [] # type: typing.List[Component] container = None # type: typing.Optional[Component] component = None # type: typing.Optional[Component] has_container_runtime = False inherit_ldlp_runtime = True reaper = self.reaper if reaper is not None: components.append(reaper) launch_wrapper = self.launch_wrapper if launch_wrapper is not None: components.append(launch_wrapper) selected = self.container_runtime_combo.get_active_id() if selected is None or not selected or selected == '/': container = None else: container = self.container_runtimes.get(selected) if container is not None: components.append(container) selected = self.layered_runtime_combo.get_active_id() if selected is None or not selected or selected == '/': component = None else: component = self.layered_runtimes.get(selected) if component is not None: components.append(component) component = None selected = self.ldlp_runtime_combo.get_active_id() if selected is None or not selected: pass elif selected == '/': # TODO: Shouldn't be allowed? pass else: component = self.ldlp_runtimes.get(selected) if component is not None: environ['STEAM_RUNTIME_SCOUT'] = component.path lines.append(to_shell([ 'env', 'STEAM_RUNTIME_SCOUT={}'.format(component.path), ])) else: selected = self.ldlp_runtime_combo.get_active_id() component = None if selected is None or not selected: pass elif selected == '/': inherit_ldlp_runtime = False else: component = self.ldlp_runtimes.get(selected) if component is not None: components.append(component) selected = self.proton_combo.get_active_id() if selected is None or not selected or selected == '/': component = None else: component = self.proton_versions.get(selected) if component is not None: components.append(component) if inherit_ldlp_runtime: environ.update(self.steam_runtime_env) for component in components: assert component is not None args = component.argv[:] if isinstance(component, ContainerRuntime): has_container_runtime = True selected = self.pressure_vessel_combo.get_active_id() assert selected is not None assert selected assert selected != '/' pv = self.pressure_vessels[selected] assert pv is not None if isinstance(component, ContainerRuntimeDepot): its_pv = component.pressure_vessel if ( its_pv is None or pv.path != its_pv.path or environ.get('PRESSURE_VESSEL_PREFIX') != pv.path ): environ['PRESSURE_VESSEL_PREFIX'] = pv.path else: args = [ pv.unruntime, ] if isinstance(component, ArchiveRuntime): args.append( '--runtime-archive=' + component.path ) args.append( '--runtime-id=' + component.deploy_id ) elif isinstance(component, DirectoryRuntime): args.append('--runtime=' + component.path) else: raise AssertionError( 'Unhandled ContainerRuntime subclass: %r' % component ) args.append('--') value = self.graphics_provider_combo.get_active_id() if value is not None: environ['PRESSURE_VESSEL_GRAPHICS_PROVIDER'] = value value = self.share_home_combo.get_active_id() if value is not None: environ['PRESSURE_VESSEL_SHARE_HOME'] = value value = self.share_pid_combo.get_active_id() if value is not None: environ['PRESSURE_VESSEL_SHARE_PID'] = value value = self.remove_game_overlay_combo.get_active_id() if value is not None: environ['PRESSURE_VESSEL_REMOVE_GAME_OVERLAY'] = value value = self.vulkan_layers_combo.get_active_id() if value is not None: environ['PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS'] = value value = self.launcher_service_combo.get_active_id() if value is not None: environ['STEAM_COMPAT_LAUNCHER_SERVICE'] = value value = self.terminal_combo.get_active_id() if value is not None: environ['PRESSURE_VESSEL_TERMINAL'] = value value = self.shell_combo.get_active_id() if value is not None: environ['PRESSURE_VESSEL_SHELL'] = value var_path = self.var_path_entry.get_text() if var_path: os.makedirs(var_path, mode=0o755, exist_ok=True) environ['PRESSURE_VESSEL_VARIABLE_DIR'] = var_path lines.append(to_shell(args)) argv.extend(args) value = self.sdl_videodriver_combo.get_active_id() if value is not None: environ['SDL_VIDEODRIVER'] = value if self.debug_check.get_active(): environ['STEAM_LINUX_RUNTIME_VERBOSE'] = '1' environ['G_MESSAGES_DEBUG'] = 'all' shell = self.shell_combo.get_active_id() terminal = self.terminal_combo.get_active_id() # If we're not using a container runtime, we can try to borrow the # pressure-vessel-adverb from any random copy of pressure-vessel # to get its "run in an xterm" code if ( (shell is not None or terminal is not None) and not has_container_runtime ): any_pv = self.default_pressure_vessel if any_pv is None and self.pressure_vessels: any_pv = next(iter(sorted(self.pressure_vessels.items())))[1] if any_pv is not None: adverb = [any_pv.adverb] if shell is not None: adverb.append('--shell=' + shell) if terminal is not None: adverb.append('--terminal=' + terminal) adverb.append('--') argv.extend(adverb) lines.append(to_shell(adverb)) argv.extend(self.app.argv) lines.append(to_shell(self.app.argv)) env_lines = [] # type: typing.List[str] for var, value in sorted(environ.items()): if value != os.environ.get(var): env_lines.append('{}={}'.format(var, to_shell([value]))) lines = env_lines + lines self.final_command_view.get_buffer().set_text( ' \\\n'.join(lines), -1, ) final_env = {} # type: typing.Dict[str, str] final_env.update(os.environ) final_env.update(environ) # The older pressure-vessel-test-ui would be redundant here, # so disable it. if 'PRESSURE_VESSEL_WRAP_GUI' in final_env: del final_env['PRESSURE_VESSEL_WRAP_GUI'] return argv, final_env def run(self): # type: (...) -> None self.window.show_all() Gtk.main() if self.failed: sys.exit(126) if __name__ == '__main__': logging.basicConfig() logging.getLogger().setLevel(logging.INFO) if '--check-gui-dependencies' in sys.argv: sys.exit(0) try: gui = Gui() gui.parse_args(sys.argv[1:]) gui.run() except KeyboardInterrupt: sys.exit(130) except Exception as e: logger.exception(str(e)) sys.exit(125) except SystemExit: # Catch exit(2) from argparse.ArgumentParser.error, because we # want to reserve exit statuses < 125 for the launched tool sys.exit(125)