[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)
This commit is contained in:
derrod 2021-12-30 17:21:56 +01:00
parent 8512a9a7a1
commit 013792f7b9
3 changed files with 205 additions and 6 deletions

View file

@ -22,7 +22,9 @@ from legendary.core import LegendaryCore
from legendary.models.exceptions import InvalidCredentialsError from legendary.models.exceptions import InvalidCredentialsError
from legendary.models.game import SaveGameStatus, VerifyResult, Game 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.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.custom_parser import AliasedSubParsersAction
from legendary.utils.env import is_windows_mac_or_pyi from legendary.utils.env import is_windows_mac_or_pyi
from legendary.utils.eos import add_registry_entries, query_registry_entries, remove_registry_entries from legendary.utils.eos import add_registry_entries, query_registry_entries, remove_registry_entries
@ -551,7 +553,7 @@ class LegendaryCLI:
# Interactive CrossOver setup # Interactive CrossOver setup
if args.crossover and sys_platform == 'darwin': if args.crossover and sys_platform == 'darwin':
args.reset = False args.reset = args.download = False
self.crossover_setup(args) self.crossover_setup(args)
if args.origin: if args.origin:
@ -2125,12 +2127,82 @@ class LegendaryCLI:
f'for setup instructions') f'for setup instructions')
return return
forced_selection = None
bottles = mac_get_crossover_bottles() 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 ' logger.info('It is recommended to set up a bottle specifically for Legendary, see '
'https://legendary.gl/crossover-setup for setup instructions.') '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:') print('Found multiple CrossOver bottles, please select one:')
if 'Legendary' in bottles: if 'Legendary' in bottles:
@ -2154,9 +2226,11 @@ class LegendaryCLI:
exit(1) exit(1)
args.crossover_bottle = bottles[choice - 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]}') logger.info(f'Found only one bottle: {bottles[0]}')
args.crossover_bottle = bottles[0] args.crossover_bottle = bottles[0]
elif forced_selection:
args.crossover_bottle = forced_selection
else: else:
logger.error('No Bottles found, see https://legendary.gl/crossover-setup for setup instructions.') logger.error('No Bottles found, see https://legendary.gl/crossover-setup for setup instructions.')
return return
@ -2516,6 +2590,8 @@ def main():
cx_parser.add_argument('--reset', dest='reset', action='store_true', cx_parser.add_argument('--reset', dest='reset', action='store_true',
help='Reset default/app-specific crossover configuration') 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() args, extra = parser.parse_known_args()

View file

@ -33,7 +33,7 @@ from legendary.models.game import *
from legendary.models.json_manifest import JSONManifest from legendary.models.json_manifest import JSONManifest
from legendary.models.manifest import Manifest, ManifestMeta from legendary.models.manifest import Manifest, ManifestMeta
from legendary.models.chunk import Chunk 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.egl_crypt import decrypt_epic_data
from legendary.utils.env import is_windows_mac_or_pyi from legendary.utils.env import is_windows_mac_or_pyi
from legendary.utils.eos import EOSOverlayApp, query_registry_entries from legendary.utils.eos import EOSOverlayApp, query_registry_entries
@ -1828,6 +1828,76 @@ class LegendaryCore:
delete_folder(igame.install_path, recursive=True) delete_folder(igame.install_path, recursive=True)
self.lgd.remove_overlay_install_info() 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): def exit(self):
""" """
Do cleanup, config saving, and exit. Do cleanup, config saving, and exit.

View file

@ -5,6 +5,50 @@ import subprocess
logger = logging.getLogger('CXHelpers') 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): def mac_get_crossover_version(app_path):
try: try:
@ -48,3 +92,12 @@ def mac_get_crossover_bottles():
def mac_is_valid_bottle(bottle_name): def mac_is_valid_bottle(bottle_name):
bottles_path = os.path.expanduser('~/Library/Application Support/CrossOver/Bottles') bottles_path = os.path.expanduser('~/Library/Application Support/CrossOver/Bottles')
return os.path.exists(os.path.join(bottles_path, bottle_name, 'cxbottle.conf')) 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