From 013792f7b9630a826b5275c03d2f34bafa991f34 Mon Sep 17 00:00:00 2001 From: derrod Date: Thu, 30 Dec 2021 17:21:56 +0100 Subject: [PATCH] [cli/core/utils] Add experimental automatic bottle setup Not sure if this will make it into the release yet, but it doesn't seem like a bad idea. And it should work even if the user has never run CrossOver. It's quite a lot of work to package a bottle this way (read: not including personal data, and without broken symlinks) --- legendary/cli.py | 86 +++++++++++++++++++++++++++++++++--- legendary/core.py | 72 +++++++++++++++++++++++++++++- legendary/utils/crossover.py | 53 ++++++++++++++++++++++ 3 files changed, 205 insertions(+), 6 deletions(-) diff --git a/legendary/cli.py b/legendary/cli.py index abb933e..9c287cf 100644 --- a/legendary/cli.py +++ b/legendary/cli.py @@ -22,7 +22,9 @@ from legendary.core import LegendaryCore from legendary.models.exceptions import InvalidCredentialsError from legendary.models.game import SaveGameStatus, VerifyResult, Game from legendary.utils.cli import get_boolean_choice, get_int_choice, sdl_prompt, strtobool -from legendary.utils.crossover import mac_find_crossover_apps, mac_get_crossover_bottles, mac_is_valid_bottle +from legendary.utils.crossover import ( + mac_find_crossover_apps, mac_get_crossover_bottles, mac_is_valid_bottle, mac_is_crossover_running +) from legendary.utils.custom_parser import AliasedSubParsersAction from legendary.utils.env import is_windows_mac_or_pyi from legendary.utils.eos import add_registry_entries, query_registry_entries, remove_registry_entries @@ -551,7 +553,7 @@ class LegendaryCLI: # Interactive CrossOver setup if args.crossover and sys_platform == 'darwin': - args.reset = False + args.reset = args.download = False self.crossover_setup(args) if args.origin: @@ -2125,12 +2127,82 @@ class LegendaryCLI: f'for setup instructions') return + forced_selection = None bottles = mac_get_crossover_bottles() - if 'Legendary' not in bottles: + # todo support names other than Legendary for downloaded bottles + if 'Legendary' not in bottles and not args.download: logger.info('It is recommended to set up a bottle specifically for Legendary, see ' 'https://legendary.gl/crossover-setup for setup instructions.') + elif 'Legendary' in bottles and args.download: + logger.info('Legendary is already installed in a bottle, skipping download.') + forced_selection = 'Legendary' + elif args.download: + if mac_is_crossover_running(): + logger.error('CrossOver is still running, please quit it before proceeding.') + return - if len(bottles) > 1: + logger.info('Checking available bottles...') + available_bottles = self.core.get_available_bottles() + usable_bottles = [b for b in available_bottles if b['cx_version'] == cx_version] + logger.info(f'Found {len(usable_bottles)} bottles usable with the selected CrossOver version. ' + f'(Total: {len(available_bottles)})') + + if len(usable_bottles) == 0: + logger.info(f'No usable bottles found, see https://legendary.gl/crossover-setup for ' + f'manual setup instructions.') + install_candidate = None + elif len(usable_bottles) == 1: + install_candidate = usable_bottles[0] + else: + print('Found multiple available bottles, please select one:') + + default_choice = None + for i, bottle in enumerate(usable_bottles, start=1): + if bottle['is_default']: + default_choice = i + print(f'\t{i:2d}. {bottle["name"]} ({bottle["description"]}) [default]') + else: + print(f'\t{i:2d}. {bottle["name"]} ({bottle["description"]})') + + choice = get_int_choice(f'Select a bottle', default_choice, 1, len(usable_bottles)) + if choice is None: + logger.error(f'No valid choice made, aborting.') + return + + install_candidate = usable_bottles[choice - 1] + + if install_candidate: + logger.info(f'Preparing to download "{install_candidate["name"]}" ' + f'({install_candidate["description"]})...') + dlm, ares, path = self.core.prepare_bottle_download(install_candidate['name'], + install_candidate['manifest']) + + logger.info(f'Bottle install directory: {path}') + logger.info(f'Bottle size: {ares.install_size / 1024 / 1024:.2f} MiB') + logger.info(f'Download size: {ares.dl_size / 1024 / 1024:.2f} MiB') + + if not args.yes: + if not get_boolean_choice('Do you want to download the selected bottle?'): + print('Aborting...') + return + + try: + # set up logging stuff (should be moved somewhere else later) + dlm.logging_queue = self.logging_queue + dlm.start() + dlm.join() + except Exception as e: + logger.error(f'The following exception occurred while waiting for the downloader: {e!r}. ' + f'Try restarting the process, if it continues to fail please open an issue on GitHub.') + # delete the unfinished bottle + self.core.remove_bottle(install_candidate['name']) + return + else: + logger.info('Finished downloading, finalising bottle setup...') + self.core.finish_bottle_setup(install_candidate['name']) + forced_selection = install_candidate['name'] + + if len(bottles) > 1 and not forced_selection: print('Found multiple CrossOver bottles, please select one:') if 'Legendary' in bottles: @@ -2154,9 +2226,11 @@ class LegendaryCLI: exit(1) args.crossover_bottle = bottles[choice - 1] - elif len(bottles) == 1: + elif len(bottles) == 1 and not forced_selection: logger.info(f'Found only one bottle: {bottles[0]}') args.crossover_bottle = bottles[0] + elif forced_selection: + args.crossover_bottle = forced_selection else: logger.error('No Bottles found, see https://legendary.gl/crossover-setup for setup instructions.') return @@ -2516,6 +2590,8 @@ def main(): cx_parser.add_argument('--reset', dest='reset', action='store_true', help='Reset default/app-specific crossover configuration') + cx_parser.add_argument('--download', dest='download', action='store_true', + help='Automatically download and set up a preconfigured bottle (experimental)') args, extra = parser.parse_known_args() diff --git a/legendary/core.py b/legendary/core.py index 736cd49..72114b4 100644 --- a/legendary/core.py +++ b/legendary/core.py @@ -33,7 +33,7 @@ from legendary.models.game import * from legendary.models.json_manifest import JSONManifest from legendary.models.manifest import Manifest, ManifestMeta from legendary.models.chunk import Chunk -from legendary.utils.crossover import mac_find_crossover_apps, mac_get_crossover_version +from legendary.utils.crossover import mac_find_crossover_apps, mac_get_crossover_version, EMPTY_BOTTLE_DIRECTORIES from legendary.utils.egl_crypt import decrypt_epic_data from legendary.utils.env import is_windows_mac_or_pyi from legendary.utils.eos import EOSOverlayApp, query_registry_entries @@ -1828,6 +1828,76 @@ class LegendaryCore: delete_folder(igame.install_path, recursive=True) self.lgd.remove_overlay_install_info() + def get_available_bottles(self): + self.check_for_updates(force=True) + lgd_version_data = self.lgd.get_cached_version() + return lgd_version_data.get('data', {}).get('cx_bottles', []) + + def prepare_bottle_download(self, bottle_name, manifest_url): + r = self.egs.unauth_session.get(manifest_url) + r.raise_for_status() + manifest = self.load_manifest(r.content) + base_url = manifest_url.rpartition('/')[0] + + bottles_dir = os.path.expanduser('~/Library/Application Support/CrossOver/Bottles') + path = os.path.join(bottles_dir, bottle_name) + + if os.path.exists(path): + raise FileExistsError(f'Bottle {bottle_name} already exists') + + dlm = DLManager(path, base_url) + analysis_result = dlm.run_analysis(manifest=manifest) + + install_size = analysis_result.install_size + + parent_dir = path + while not os.path.exists(parent_dir): + parent_dir, _ = os.path.split(parent_dir) + + _, _, free = shutil.disk_usage(parent_dir) + if free < install_size: + raise ValueError(f'Not enough space to setup bottle: {free / 1024 / 1024:.02f} ' + f'MiB < {install_size / 1024 / 1024:.02f} MiB') + + return dlm, analysis_result, path + + def finish_bottle_setup(self, bottle_name): + bottles_dir = os.path.expanduser('~/Library/Application Support/CrossOver/Bottles') + path = os.path.join(bottles_dir, bottle_name) + + self.log.info('Creating missing folders...') + os.makedirs(os.path.join(path, 'dosdevices'), exist_ok=True) + for _dir in EMPTY_BOTTLE_DIRECTORIES: + os.makedirs(os.path.join(path, 'drive_c', _dir), exist_ok=True) + + self.log.info('Creating bottle symlinks...') + symlinks = [ + ('dosdevices/c:', '../drive_c'), + ('dosdevices/y:', os.path.expanduser('~')), + ('dosdevices/z:', '/'), + ('drive_c/users/crossover/Desktop/My Mac Desktop', os.path.expanduser('~/Desktop')), + ('drive_c/users/crossover/Downloads', os.path.expanduser('~/Downloads')), + ('drive_c/users/crossover/My Documents', os.path.expanduser('~/Documents')), + ('drive_c/users/crossover/My Music', os.path.expanduser('~/Music')), + ('drive_c/users/crossover/My Pictures', os.path.expanduser('~/Pictures')), + ('drive_c/users/crossover/My Videos', os.path.expanduser('~/Movies')), + ('drive_c/users/crossover/Templates', os.path.join(path, 'dosdevices/c:/users/crossover/My Documents')), + ] + + for link, target in symlinks: + _link = os.path.join(path, link) + try: + os.symlink(target, _link) + except Exception as e: + self.log.error(f'Failed to create symlink {_link} -> {target}: {e!r}') + + @staticmethod + def remove_bottle(self, bottle_name): + bottles_dir = os.path.expanduser('~/Library/Application Support/CrossOver/Bottles') + path = os.path.join(bottles_dir, bottle_name) + if os.path.exists(path): + delete_folder(path, recursive=True) + def exit(self): """ Do cleanup, config saving, and exit. diff --git a/legendary/utils/crossover.py b/legendary/utils/crossover.py index 114dd4a..b0d85f8 100644 --- a/legendary/utils/crossover.py +++ b/legendary/utils/crossover.py @@ -5,6 +5,50 @@ import subprocess logger = logging.getLogger('CXHelpers') +# all the empty folders found in a freshly created bottle that we will need to create +EMPTY_BOTTLE_DIRECTORIES = [ + 'Program Files/Common Files/Microsoft Shared/TextConv', + 'ProgramData/Microsoft/Windows/Start Menu/Programs/Administrative Tools', + 'ProgramData/Microsoft/Windows/Start Menu/Programs/StartUp', + 'ProgramData/Microsoft/Windows/Templates', + 'users/crossover/AppData/LocalLow', + 'users/crossover/Application Data/Microsoft/Windows/Themes', + 'users/crossover/Contacts', + 'users/crossover/Cookies', + 'users/crossover/Desktop', + 'users/crossover/Favorites', + 'users/crossover/Links', + 'users/crossover/Local Settings/Application Data/Microsoft', + 'users/crossover/Local Settings/History', + 'users/crossover/Local Settings/Temporary Internet Files', + 'users/crossover/NetHood', + 'users/crossover/PrintHood', + 'users/crossover/Recent', + 'users/crossover/Saved Games', + 'users/crossover/Searches', + 'users/crossover/SendTo', + 'users/crossover/Start Menu/Programs/Administrative Tools', + 'users/crossover/Start Menu/Programs/StartUp', + 'users/crossover/Temp', + 'users/Public/Desktop', + 'users/Public/Documents', + 'users/Public/Favorites', + 'users/Public/Music', + 'users/Public/Pictures', + 'users/Public/Videos', + 'windows/Fonts', + 'windows/help', + 'windows/logs', + 'windows/Microsoft.NET/DirectX for Managed Code', + 'windows/system32/mui', + 'windows/system32/spool/printers', + 'windows/system32/tasks', + 'windows/syswow64/drivers', + 'windows/syswow64/mui', + 'windows/tasks', + 'windows/temp' +] + def mac_get_crossover_version(app_path): try: @@ -48,3 +92,12 @@ def mac_get_crossover_bottles(): def mac_is_valid_bottle(bottle_name): bottles_path = os.path.expanduser('~/Library/Application Support/CrossOver/Bottles') return os.path.exists(os.path.join(bottles_path, bottle_name, 'cxbottle.conf')) + + +def mac_is_crossover_running(): + try: + out = subprocess.check_output(['launchctl', 'list']) + return b'com.codeweavers.CrossOver' in out + except Exception as e: + logger.warning(f'Getting list of running application bundles failed: {e!r}') + return True # assume the worst