From 46f8445a06c2a4b1a074351804f11bca271bca73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Lidwin?= Date: Tue, 28 Apr 2026 11:55:18 +0200 Subject: [PATCH] feat: support launching games with uplay protocol (#750) --- legendary/cli.py | 33 ++++++++++++++++++--------------- legendary/core.py | 21 +++++++++++++++++++++ legendary/models/game.py | 6 +++++- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/legendary/cli.py b/legendary/cli.py index 0a5df55..5a93a78 100644 --- a/legendary/cli.py +++ b/legendary/cli.py @@ -579,9 +579,9 @@ class LegendaryCLI: args.reset = args.download = args.disable_version_check = False self.crossover_setup(args) - if args.origin: - return self._launch_origin(args) - + if args.origin or args.ubisoft: + return self._launch_third_party(args) + igame = self.core.get_installed_game(app_name) if (not igame or not igame.executable) and (game := self.core.get_game(app_name)) is not None: # override installed game with base title @@ -719,15 +719,15 @@ class LegendaryCLI: f'{k}={v}' for k, v in params.environment.items()))) subprocess.Popen(full_params, cwd=params.working_directory, env=full_env) - def _launch_origin(self, args): + def _launch_third_party(self, args): game = self.core.get_game(app_name=args.app_name) if not game: logger.error(f'Unknown game "{args.app_name}", run "legendary list-games --third-party" ' f'to fetch data for Origin titles before using this command.') return - if not game.is_origin_game: - logger.error(f'The specified game is not an Origin title.') + if not game.is_origin_game and not game.is_ubisoft_game: + logger.error(f'The specified game is not an Origin or Ubisoft title.') return # login is not required to launch the game, but linking does require it. @@ -737,9 +737,10 @@ class LegendaryCLI: logger.error('Login failed, cannot continue!') exit(1) - origin_uri = self.core.get_origin_uri(args.app_name, args.offline) + uri = self.core.get_origin_uri(args.app_name, args.offline) if game.is_origin_game else self.core.get_ubisoft_uri(args.app_name, args.offline) if args.json: - return self._print_json(dict(uri=origin_uri), args.pretty_json) + self._print_json(dict(uri=uri), args.pretty_json) + return if os.name == 'nt': cmd, wait_for_exit = self.core.get_pre_launch_command(args.app_name) @@ -747,7 +748,7 @@ class LegendaryCLI: if args.dry_run: if cmd: logger.info(f'Pre-launch command: {cmd}') - logger.info(f'Origin URI: {origin_uri}') + logger.info(f'URI: {uri}') else: if cmd: try: @@ -759,8 +760,8 @@ class LegendaryCLI: except Exception as e: logger.warning(f'Pre-launch command failed: {e!r}') - logger.debug(f'Opening Origin URI: {origin_uri}') - webbrowser.open(origin_uri) + logger.debug(f'Opening URI: {uri}') + webbrowser.open(uri) return # on linux, require users to specify at least the wine binary and prefix in config or command line @@ -791,17 +792,17 @@ class LegendaryCLI: logger.info(f'Using CrossOver Bottle "{bottle_name}"') if not command: - logger.error(f'In order to launch Origin correctly you must specify a prefix and wine binary or ' + logger.error(f'In order to launch correctly you must specify a prefix and wine binary or ' f'wrapper in the configuration file or command line. See the README for details.') return # You cannot launch a URI without start.exe command.append('start') - command.append(origin_uri) + command.append(uri) if args.dry_run: if cmd: logger.info(f'Pre-launch command: {cmd}') - logger.info(f'Origin launch command: {shlex.join(command)}') + logger.info(f'Launch command: {shlex.join(command)}') else: if cmd: try: @@ -813,7 +814,7 @@ class LegendaryCLI: except Exception as e: logger.warning(f'Pre-launch command failed: {e!r}') - logger.debug(f'Opening Origin URI with command: {shlex.join(command)}') + logger.debug(f'Opening URI with command: {shlex.join(command)}') subprocess.Popen(command, env=full_env) def install_game(self, args): @@ -2841,6 +2842,8 @@ def main(): help='Override executable to launch (relative path)') launch_parser.add_argument('--origin', dest='origin', action='store_true', help='Launch Origin to activate or run the game.') + launch_parser.add_argument('--ubisoft', dest='ubisoft', action='store_true', + help='Launch Ubisoft to install and run the game.') launch_parser.add_argument('--json', dest='json', action='store_true', help='Print launch information as JSON and exit') diff --git a/legendary/core.py b/legendary/core.py index d2d8214..ec50a07 100644 --- a/legendary/core.py +++ b/legendary/core.py @@ -829,6 +829,27 @@ class LegendaryCore: parameters.extend(parse_qsl(extra_args)) return f'link2ea://launchgame/{app_name}?{urlencode(parameters)}' + + def get_ubisoft_uri(self, app_name: str, offline: bool = False) -> str: + token = '0' if offline else self.egs.get_game_token()['code'] + + user_name = self.lgd.userdata['displayName'] + account_id = self.lgd.userdata['account_id'] + parameters = [ + ('AUTH_PASSWORD', token), + ('AUTH_TYPE', 'exchangecode'), + ('epicusername', user_name), + ('epicuserid', account_id), + ('epiclocale', self.language_code), + ] + + game = self.get_game(app_name) + game_id = game.metadata.get('customAttributes', {}).get('GameID', {}).get('value') or app_name + extra_args = game.metadata.get('customAttributes', {}).get('AdditionalCommandline', {}).get('value') + if extra_args: + parameters.extend(parse_qsl(extra_args)) + + return f'uplay://launch/{game_id}?{urlencode(parameters)}' def get_save_games(self, app_name: str = ''): savegames = self.egs.get_user_cloud_saves(app_name, manifests=not not app_name) diff --git a/legendary/models/game.py b/legendary/models/game.py index 077261f..e281036 100644 --- a/legendary/models/game.py +++ b/legendary/models/game.py @@ -85,6 +85,10 @@ class Game: def is_dlc(self): return self.metadata and 'mainGameItem' in self.metadata + @property + def is_ubisoft_game(self) -> bool: + return self.third_party_store and self.third_party_store.lower() in ['ubisoftconnect'] + @property def is_origin_game(self) -> bool: return self.third_party_store and self.third_party_store.lower() in ['origin', 'the ea app'] @@ -93,7 +97,7 @@ class Game: def third_party_store(self) -> Optional[str]: if not self.metadata: return None - return self.metadata.get('customAttributes', {}).get('ThirdPartyManagedApp', {}).get('value', None) + return self.metadata.get('customAttributes', {}).get('ThirdPartyManagedApp', {}).get('value', None) or self.metadata.get('customAttributes', {}).get('ThirdPartyManagedProvider', {}).get('value', None) @property def partner_link_type(self):