"""Injects sets of DLLs into a prefix""" import json import os import shutil from gettext import gettext as _ from lutris import settings from lutris.api import get_runtime_versions from lutris.exceptions import MissingRuntimeComponentError from lutris.util import system from lutris.util.extract import extract_archive from lutris.util.http import download_file from lutris.util.log import logger from lutris.util.strings import parse_version from lutris.util.wine.prefix import WinePrefixManager class DLLManager: """Utility class to install dlls to a Wine prefix""" name = NotImplemented human_name = NotImplemented managed_dlls = NotImplemented managed_appdata_files = [] # most managers have none releases_url = NotImplemented archs = {32: "x32", 64: "x64"} proton_compatible = False # Proton manages its own DLLs def __init__(self, prefix=None, arch="win64", version=None): self.prefix = prefix self._versions = [] self._version = version self.wine_arch = arch @property def base_dir(self): return os.path.join(settings.RUNTIME_DIR, self.name) @property def versions_path(self): return os.path.join(self.base_dir, f"{self.name}_versions.json") @property def versions(self): """Return available versions""" self._versions = self.load_versions() if system.path_exists(self.base_dir): for local_version in os.listdir(self.base_dir): if os.path.isdir(os.path.join(self.base_dir, local_version)) and local_version not in self._versions: self._versions.append(local_version) return self._versions @property def version(self): """Return version (latest known version if not provided)""" if self._version: return self._version versions = self.versions if versions: def get_preference_key(v): return not self.is_compatible_version(v), not self.is_recommended_version(v) # Put the compatible versions first, and the recommended ones before unrecommended ones. sorted_versions = sorted(versions, key=get_preference_key) return sorted_versions[0] def is_recommended_version(self, version): """True if the version given should be usable as the default; false if it should not be the default, but may be selected by the user. If only non-recommended versions exist, we'll still default to one of them, however.""" return True def is_compatible_version(self, version): """True if the version of the component is compatible with this Lutris. We can tell only once it is downloaded; if not this is always True. This checks the file 'lutris.json', which may contain the lowest version of Lutris the component version will work with. If this setting is absent, it is assumed compatible.""" dir_settings = settings.get_lutris_directory_settings(self.base_dir) try: if "min_lutris_version" in dir_settings: min_lutris_version = parse_version(dir_settings["min_lutris_version"]) current_lutris_version = parse_version(settings.VERSION) if current_lutris_version < min_lutris_version: return False except TypeError as ex: logger.exception("Invalid lutris.json: %s", ex) return False return True @property def path(self): """Path to local folder containing DLLs""" version = self.version if not version: raise MissingRuntimeComponentError( _( "The '%s' runtime component is not installed; " "visit the Updates tab in the Preferences dialog to install it." ) % self.human_name, component_name=self.name, ) return os.path.join(self.base_dir, version) @property def version_choices(self): _choices = [ (_("Manual"), "manual"), ] for version in self.versions: _choices.append((version, version)) return _choices def load_versions(self) -> list: if not system.path_exists(self.versions_path): return [] with open(self.versions_path, "r", encoding="utf-8") as dll_version_file: try: dll_versions = [v["tag_name"] for v in json.load(dll_version_file)] except (KeyError, json.decoder.JSONDecodeError): logger.warning( "Invalid versions file %s, deleting so it is downloaded on next start.", self.versions_path ) os.remove(self.versions_path) return [] # Ensure the versions.json specified version is present and # is the default by moving it to the top. versions = get_runtime_versions() runtimes = versions.get("runtimes") if runtimes: runtime = runtimes.get(self.name) if runtime and runtime.get("versioned"): default_version = runtime.get("version") if default_version: if default_version in dll_versions: dll_versions.remove(default_version) dll_versions.insert(0, default_version) return dll_versions @staticmethod def is_managed_dll(dll_path): """Check if a given DLL path is provided by the component""" return False def is_available(self): """Return whether component is cached locally""" return self.version and system.path_exists(self.path) def dll_exists(self, dll_name): """Check if the dll is provided by the component The DLL might not be available for all architectures so only check if one exists for the supported ones """ return any(system.path_exists(os.path.join(self.path, arch, dll_name + ".dll")) for arch in self.archs.values()) def get_download_url(self): """Fetch the download URL from the JSON version file""" with open(self.versions_path, "r", encoding="utf-8") as version_file: releases = json.load(version_file) for release in releases: if release["tag_name"] != self.version: continue return release["assets"][0]["browser_download_url"] def download(self): """Download component to the local cache; returns True if successful but False if the component could not be downloaded.""" if self.is_available(): logger.warning("%s already available at %s", self.human_name, self.path) if not system.path_exists(self.versions_path): self.fetch_versions() url = self.get_download_url() if not url: logger.warning("Could not find a release for %s %s", self.human_name, self.version) return False archive_path = os.path.join(self.base_dir, os.path.basename(url)) logger.info("Downloading %s to %s", url, archive_path) download_file(url, archive_path, overwrite=True) if not system.path_exists(archive_path) or not os.stat(archive_path).st_size: logger.error("Failed to download %s %s", self.human_name, self.version) return False logger.info("Extracting %s to %s", archive_path, self.path) extract_archive(archive_path, self.path, merge_single=True) os.remove(archive_path) return True def enable_dll(self, system_dir, arch, dll_path): """Copies dlls to the appropriate destination""" dll = os.path.basename(dll_path) if system.path_exists(dll_path): wine_dll_path = os.path.join(system_dir, dll) if system.path_exists(wine_dll_path): if not self.is_managed_dll(wine_dll_path) and not os.path.islink(wine_dll_path): # Backing up original version (may not be needed) shutil.move(wine_dll_path, wine_dll_path + ".orig") else: os.remove(wine_dll_path) system.create_symlink(dll_path, wine_dll_path) else: self.disable_dll(system_dir, arch, dll) def disable_dll(self, system_dir, _arch, dll): # pylint: disable=unused-argument """Remove DLL from Wine prefix""" wine_dll_path = os.path.join(system_dir, "%s.dll" % dll) if system.path_exists(wine_dll_path + ".orig"): if system.path_exists(wine_dll_path): os.remove(wine_dll_path) shutil.move(wine_dll_path + ".orig", wine_dll_path) def enable_user_file(self, appdata_dir, file_path, source_path): if system.path_exists(source_path): wine_file_path = os.path.join(appdata_dir, file_path) wine_file_dir = os.path.dirname(wine_file_path) if system.path_exists(wine_file_path): if not os.path.islink(wine_file_path): # Backing up original version (may not be needed) shutil.move(wine_file_path, wine_file_path + ".orig") else: os.remove(wine_file_path) if not os.path.isdir(wine_file_dir): os.makedirs(wine_file_dir) system.create_symlink(source_path, wine_file_path) else: self.disable_user_file(appdata_dir, file_path) def disable_user_file(self, appdata_dir, file_path): wine_file_path = os.path.join(appdata_dir, file_path) # We only create a symlink; if it is a real file, it mus tbe user data. if system.path_exists(wine_file_path) and os.path.islink(wine_file_path): os.remove(wine_file_path) if system.path_exists(wine_file_path + ".orig"): shutil.move(wine_file_path + ".orig", wine_file_path) def _iter_dlls(self): windows_path = os.path.join(self.prefix, "drive_c/windows") if self.wine_arch == "win64": system_dirs = { self.archs[64]: os.path.join(windows_path, "system32"), self.archs[32]: os.path.join(windows_path, "syswow64"), } elif self.wine_arch == "win32": system_dirs = {self.archs[32]: os.path.join(windows_path, "system32")} for arch, system_dir in system_dirs.items(): for dll in self.managed_dlls: yield system_dir, arch, dll def _iter_appdata_files(self): if self.managed_appdata_files: prefix_manager = WinePrefixManager(self.prefix) appdata_dir = prefix_manager.appdata_dir for file in self.managed_appdata_files: filename = os.path.basename(file) yield appdata_dir, file, filename def setup(self, enable): """Enable or disable DLLs""" # manual version only sets the dlls to native (in get_enabling_dll_overrides()) manager_version = self.version if not manager_version or manager_version.lower() != "manual": if enable: self.enable() else: self.disable() def get_enabling_dll_overrides(self): """Returns aa dll-override dict for the dlls in this manager; these options will enable the manager's dll, so call this only for enabled managers.""" overrides = {} for dll in self.managed_dlls: # We have to make sure that the dll exists before setting it to native if self.dll_exists(dll): overrides[dll] = "n" return overrides def can_enable(self): return True def enable(self): """Enable Dlls for the current prefix""" if not self.is_available(): if not self.download(): logger.error( "%s %s could not be enabled because it is not available locally", self.human_name, self.version ) return for system_dir, arch, dll in self._iter_dlls(): dll_path = os.path.join(self.path, arch, "%s.dll" % dll) self.enable_dll(system_dir, arch, dll_path) for appdata_dir, file, filename in self._iter_appdata_files(): source_path = os.path.join(self.path, filename) self.enable_user_file(appdata_dir, file, source_path) def disable(self): """Disable DLLs for the current prefix""" for system_dir, arch, dll in self._iter_dlls(): self.disable_dll(system_dir, arch, dll) for appdata_dir, file, _filename in self._iter_appdata_files(): self.disable_user_file(appdata_dir, file) def fetch_versions(self): """Get releases from GitHub""" if not os.path.isdir(self.base_dir): os.mkdir(self.base_dir) download_file(self.releases_url, self.versions_path, overwrite=True) def upgrade(self): if not self.is_available(): versions = self.load_versions() if not versions: logger.warning("Unable to download %s because version information was not available.", self.human_name) # We prefer recommended versions, so download those first. versions.sort(key=self.is_recommended_version, reverse=True) for version in versions: logger.info("Downloading %s %s...", self.human_name, version) self.download() if self.is_compatible_version(version): return # got a compatible version, that'll do. logger.warning( "Version %s of %s is not compatible with this version of Lutris.", version, self.human_name ) # We found nothing compatible, and downloaded everything, we just give up.