class BNetPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.Battlenet, version, reader, writer, token) self.local_client = LocalClient(self._update_statuses) self.authentication_client = AuthenticatedHttpClient(self) self.backend_client = BackendClient(self, self.authentication_client) self.watched_running_games = set() def handshake_complete(self): self.create_task(self.__delayed_handshake(), 'delayed handshake') async def __delayed_handshake(self): """ Adds some minimal delay on Galaxy start before registering local data watchers. Apparently Galaxy may be not ready to receive notifications even after handshake_complete. """ await asyncio.sleep(1) self.create_task(self.local_client.register_local_data_watcher(), 'local data watcher') self.create_task(self.local_client.register_classic_games_updater(), 'classic games updater') async def _notify_about_game_stop(self, game, starting_timeout): id_to_watch = game.info.uid if id_to_watch in self.watched_running_games: log.debug(f'Game {id_to_watch} is already watched. Skipping') return try: self.watched_running_games.add(id_to_watch) await asyncio.sleep(starting_timeout) ProcessProvider().update_games_processes([game]) log.info(f'Setuping process watcher for {game._processes}') loop = asyncio.get_event_loop() await loop.run_in_executor(None, game.wait_until_game_stops) finally: self.update_local_game_status( LocalGame(id_to_watch, LocalGameState.Installed)) self.watched_running_games.remove(id_to_watch) def _update_statuses(self, refreshed_games, previous_games): for blizz_id, refr in refreshed_games.items(): prev = previous_games.get(blizz_id, None) if prev is None: if refr.has_galaxy_installed_state: log.debug('Detected playable game') state = LocalGameState.Installed else: log.debug('Detected not-fully installed game') continue elif refr.has_galaxy_installed_state and not prev.has_galaxy_installed_state: log.debug('Detected playable game') state = LocalGameState.Installed elif refr.last_played != prev.last_played: log.debug('Detected launched game') state = LocalGameState.Installed | LocalGameState.Running self.create_task(self._notify_about_game_stop(refr, 5), 'game stop waiter') else: continue log.info(f'Changing game {blizz_id} state to {state}') self.update_local_game_status(LocalGame(blizz_id, state)) for blizz_id, prev in previous_games.items(): refr = refreshed_games.get(blizz_id, None) if refr is None: log.debug('Detected uninstalled game') state = LocalGameState.None_ self.update_local_game_status(LocalGame(blizz_id, state)) def log_out(self): if self.backend_client: asyncio.create_task(self.authentication_client.shutdown()) self.authentication_client.user_details = None async def open_battlenet_browser(self): url = self.authentication_client.blizzard_battlenet_download_url log.info(f'Opening battle.net website: {url}') loop = asyncio.get_running_loop() await loop.run_in_executor( None, lambda x: webbrowser.open(x, autoraise=True), url) async def install_game(self, game_id): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() installed_game = self.local_client.get_installed_games().get( game_id, None) if installed_game and os.access(installed_game.install_path, os.F_OK): log.warning( "Received install command on an already installed game") return await self.launch_game(game_id) if game_id in [classic.uid for classic in Blizzard.CLASSIC_GAMES]: if SYSTEM == pf.WINDOWS: platform = 'windows' elif SYSTEM == pf.MACOS: platform = 'macos' webbrowser.open( f"https://www.blizzard.com/download/confirmation?platform={platform}&locale=enUS&version=LIVE&id={game_id}" ) return try: self.local_client.refresh() log.info(f'Installing game of id {game_id}') self.local_client.install_game(game_id) except ClientNotInstalledError as e: log.warning(e) await self.open_battlenet_browser() except Exception as e: log.exception(f"Installing game {game_id} failed: {e}") def _open_battlenet_at_id(self, game_id): try: self.local_client.refresh() self.local_client.open_battlenet(game_id) except Exception as e: log.exception( f"Opening battlenet client on specific game_id {game_id} failed {e}" ) try: self.local_client.open_battlenet() except Exception as e: log.exception(f"Opening battlenet client failed {e}") async def uninstall_game(self, game_id): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() if game_id == 'wow_classic': # attempting to uninstall classic wow through protocol gives you a message that the game cannot # be uninstalled through protocol and you should use battle.net return self._open_battlenet_at_id(game_id) if SYSTEM == pf.MACOS: self._open_battlenet_at_id(game_id) else: try: installed_game = self.local_client.get_installed_games().get( game_id, None) if installed_game is None or not os.access( installed_game.install_path, os.F_OK): log.error(f'Cannot uninstall {game_id}') self.update_local_game_status( LocalGame(game_id, LocalGameState.None_)) return if not isinstance(installed_game.info, ClassicGame): if self.local_client.uninstaller is None: raise FileNotFoundError('Uninstaller not found') uninstall_tag = installed_game.uninstall_tag client_lang = self.local_client.config_parser.locale_language self.local_client.uninstaller.uninstall_game( installed_game, uninstall_tag, client_lang) except Exception as e: log.exception(f'Uninstalling game {game_id} failed: {e}') async def launch_game(self, game_id): try: game = self.local_client.get_installed_games().get(game_id, None) if game is None: log.error(f'Launching game that is not installed: {game_id}') return await self.install_game(game_id) if isinstance(game.info, ClassicGame): log.info( f'Launching game of id: {game_id}, {game} at path {os.path.join(game.install_path, game.info.exe)}' ) if SYSTEM == pf.WINDOWS: subprocess.Popen( os.path.join(game.install_path, game.info.exe)) elif SYSTEM == pf.MACOS: if not game.info.bundle_id: log.warning( f"{game.name} has no bundle id, help by providing us bundle id of this game" ) subprocess.Popen(['open', '-b', game.info.bundle_id]) self.update_local_game_status( LocalGame( game_id, LocalGameState.Installed | LocalGameState.Running)) asyncio.create_task(self._notify_about_game_stop(game, 6)) return self.local_client.refresh() log.info(f'Launching game of id: {game_id}, {game}') await self.local_client.launch_game(game, wait_sec=60) self.update_local_game_status( LocalGame(game_id, LocalGameState.Installed | LocalGameState.Running)) self.local_client.close_window() asyncio.create_task(self._notify_about_game_stop(game, 3)) except ClientNotInstalledError as e: log.warning(e) await self.open_battlenet_browser() except TimeoutError as e: log.warning(str(e)) except Exception as e: log.exception(f"Launching game {game_id} failed: {e}") async def authenticate(self, stored_credentials=None): try: if stored_credentials: auth_data = self.authentication_client.process_stored_credentials( stored_credentials) try: await self.authentication_client.create_session() await self.backend_client.refresh_cookies() auth_status = await self.backend_client.validate_access_token( auth_data.access_token) except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e: raise e except Exception: raise InvalidCredentials() if self.authentication_client.validate_auth_status( auth_status): self.authentication_client.user_details = await self.backend_client.get_user_info( ) return self.authentication_client.parse_user_details() else: return self.authentication_client.authenticate_using_login() except Exception as e: raise e async def pass_login_credentials(self, step, credentials, cookies): if "logout&app=oauth" in credentials['end_uri']: # 2fa expired, repeat authentication return self.authentication_client.authenticate_using_login() if self.authentication_client.attempted_to_set_battle_tag: self.authentication_client.user_details = await self.backend_client.get_user_info( ) return self.authentication_client.parse_auth_after_setting_battletag( ) cookie_jar = self.authentication_client.parse_cookies(cookies) auth_data = await self.authentication_client.get_auth_data_login( cookie_jar, credentials) try: await self.authentication_client.create_session() await self.backend_client.refresh_cookies() except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e: raise e except Exception: raise InvalidCredentials() auth_status = await self.backend_client.validate_access_token( auth_data.access_token) if not ("authorities" in auth_status and "IS_AUTHENTICATED_FULLY" in auth_status["authorities"]): raise InvalidCredentials() self.authentication_client.user_details = await self.backend_client.get_user_info( ) self.authentication_client.set_credentials() return self.authentication_client.parse_battletag() async def get_owned_games(self): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() def _parse_battlenet_games( standard_games: dict, cn: bool) -> Dict[BlizzardGame, LicenseType]: licenses = { None: LicenseType.Unknown, "Trial": LicenseType.OtherUserLicense, "Good": LicenseType.SinglePurchase, "Inactive": LicenseType.SinglePurchase, "Banned": LicenseType.SinglePurchase, "Free": LicenseType.FreeToPlay } games = {} for standard_game in standard_games["gameAccounts"]: title_id = standard_game['titleId'] try: game = Blizzard.game_by_title_id(title_id, cn) except KeyError: log.warning( f"Skipping unknown game with titleId: {title_id}") else: games[game] = licenses[standard_game.get( "gameAccountStatus")] # Add wow classic if retail wow is present in owned games wow_license = games.get(Blizzard['wow']) if wow_license is not None: games[Blizzard['wow_classic']] = wow_license return games def _parse_classic_games( classic_games: dict) -> Dict[ClassicGame, LicenseType]: games = {} for classic_game in classic_games["classicGames"]: sanitized_name = classic_game["localizedGameName"].replace( u'\xa0', ' ') for cg in Blizzard.CLASSIC_GAMES: if cg.name == sanitized_name: games[cg] = LicenseType.SinglePurchase break else: log.warning( f"Skipping unknown classic game with name: {sanitized_name}" ) return games cn = self.authentication_client.region == 'cn' battlenet_games = _parse_battlenet_games( await self.backend_client.get_owned_games(), cn) classic_games = _parse_classic_games( await self.backend_client.get_owned_classic_games()) owned_games: Dict[BlizzardGame, LicenseType] = { **battlenet_games, **classic_games } for game in Blizzard.try_for_free_games(cn): if game not in owned_games: owned_games[game] = LicenseType.FreeToPlay return [ Game(game.uid, game.name, None, LicenseInfo(license_type)) for game, license_type in owned_games.items() ] async def get_local_games(self): timeout = time.time() + 2 try: translated_installed_games = [] while not self.local_client.games_finished_parsing(): await asyncio.sleep(0.1) if time.time() >= timeout: break running_games = self.local_client.get_running_games() installed_games = self.local_client.get_installed_games() log.info(f"Installed games {installed_games.items()}") log.info(f"Running games {running_games}") for uid, game in installed_games.items(): if game.has_galaxy_installed_state: state = LocalGameState.Installed if uid in running_games: state |= LocalGameState.Running translated_installed_games.append(LocalGame(uid, state)) self.local_client.installed_games_cache = installed_games return translated_installed_games except Exception as e: log.exception(f"failed to get local games: {str(e)}") raise async def get_game_time(self, game_id, context): total_time = None last_played_time = None blizzard_game = Blizzard[game_id] if blizzard_game.name == "Overwatch": total_time = await self._get_overwatch_time() log.debug(f"Gametime for Overwatch is {total_time} minutes.") for config_info in self.local_client.config_parser.games: if config_info.uid == blizzard_game.uid: if config_info.last_played is not None: last_played_time = int(config_info.last_played) break return GameTime(game_id, total_time, last_played_time) async def _get_overwatch_time(self) -> Union[None, int]: log.debug("Fetching playtime for Overwatch...") player_data = await self.backend_client.get_ow_player_data() if 'message' in player_data: # user not found log.error('No Overwatch profile found.') return None if player_data['private'] == True: log.info('Unable to get data as Overwatch profile is private.') return None qp_time = player_data['playtime'].get('quickplay') if qp_time is None: # user has not played quick play return 0 if qp_time.count(':') == 1: # minutes and seconds match = re.search('(?:(?P<m>\\d+):)(?P<s>\\d+)', qp_time) if match: return int(match.group('m')) elif qp_time.count(':') == 2: # hours, minutes and seconds match = re.search('(?:(?P<h>\\d+):)(?P<m>\\d+)', qp_time) if match: return int(match.group('h')) * 60 + int(match.group('m')) raise UnknownBackendResponse( f'Unknown Overwatch API playtime format: {qp_time}') async def _get_wow_achievements(self): achievements = [] try: characters_data = await self.backend_client.get_wow_character_data( ) characters_data = characters_data["characters"] wow_character_data = await asyncio.gather( *[ self.backend_client.get_wow_character_achievements( character["realm"], character["name"]) for character in characters_data ], return_exceptions=True, ) for data in wow_character_data: if isinstance(data, requests.Timeout) or isinstance( data, requests.ConnectionError): raise data wow_achievement_data = [ list( zip( data["achievements"]["achievementsCompleted"], data["achievements"]["achievementsCompletedTimestamp"], )) for data in wow_character_data if type(data) is dict ] already_in = set() for char_ach in wow_achievement_data: for ach in char_ach: if ach[0] not in already_in: achievements.append( Achievement(achievement_id=ach[0], unlock_time=int(ach[1] / 1000))) already_in.add(ach[0]) except (AccessTokenExpired, BackendError) as e: log.exception(str(e)) with open('wow.json', 'w') as f: f.write(json.dumps(achievements, cls=DataclassJSONEncoder)) return achievements async def _get_sc2_achievements(self): account_data = await self.backend_client.get_sc2_player_data( self.authentication_client.user_details["id"]) # TODO what if more sc2 accounts? assert len(account_data) == 1 account_data = account_data[0] profile_data = await self.backend_client.get_sc2_profile_data( account_data["regionId"], account_data["realmId"], account_data["profileId"]) sc2_achievement_data = [ Achievement(achievement_id=achievement["achievementId"], unlock_time=achievement["completionDate"]) for achievement in profile_data["earnedAchievements"] if achievement["isComplete"] ] with open('sc2.json', 'w') as f: f.write(json.dumps(sc2_achievement_data, cls=DataclassJSONEncoder)) return sc2_achievement_data # async def get_unlocked_achievements(self, game_id): # if not self.website_client.is_authenticated(): # raise AuthenticationRequired() # try: # if game_id == "21298": # return await self._get_sc2_achievements() # elif game_id == "5730135": # return await self._get_wow_achievements() # else: # return [] # except requests.Timeout: # raise BackendTimeout() # except requests.ConnectionError: # raise NetworkError() # except Exception as e: # log.exception(str(e)) # return [] async def launch_platform_client(self): if self.local_client.is_running(): log.info( "Launch platform client called but client is already running") return self.local_client.open_battlenet() await self.local_client.prevent_battlenet_from_showing() async def shutdown_platform_client(self): await self.local_client.shutdown_platform_client() async def shutdown(self): log.info("Plugin shutdown.") await self.authentication_client.shutdown()
class UplayPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.Uplay, __version__, reader, writer, token) self.client = BackendClient(self) self.local_client = LocalClient() self.cached_game_statuses = {} self.games_collection = GamesCollection() self.process_watcher = ProcessWatcher() self.game_status_notifier = GameStatusNotifier(self.process_watcher) self.tick_count = 0 self.updating_games = False self.owned_games_sent = False self.parsing_club_games = False self.parsed_local_games = False def auth_lost(self): self.lost_authentication() async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", AUTH_PARAMS, cookies=COOKIES) else: try: user_data = await self.client.authorise_with_stored_credentials( stored_credentials) except (AccessDenied, AuthenticationRequired) as e: log.exception(repr(e)) raise InvalidCredentials() except Exception as e: log.exception(repr(e)) raise e else: self.local_client.initialize(user_data['userId']) self.client.set_auth_lost_callback(self.auth_lost) return Authentication(user_data['userId'], user_data['username']) async def pass_login_credentials(self, step, credentials, cookies): """Called just after CEF authentication (called as NextStep by authenticate)""" user_data = await self.client.authorise_with_cookies(cookies) self.local_client.initialize(user_data['userId']) self.client.set_auth_lost_callback(self.auth_lost) return Authentication(user_data['userId'], user_data['username']) async def get_owned_games(self): if not self.client.is_authenticated(): raise AuthenticationRequired() if SYSTEM == System.WINDOWS: self._parse_local_games() self._parse_local_game_ownership() await self._parse_club_games() try: await self._parse_subscription_games() except Exception as e: log.warning( f"Parsing subscriptions failed, most likely account without subscription {repr(e)}" ) self.owned_games_sent = True for game in self.games_collection: game.considered_for_sending = True return [ game.as_galaxy_game() for game in self.games_collection if game.owned ] async def _parse_subscription_games(self): subscription_games = [] sub_response = await self.client.get_subscription() if not sub_response: return for game in sub_response['games']: subscription_games.append( UbisoftGame(space_id='', launch_id=str(game['uplayGameId']), install_id=str(game['uplayGameId']), third_party_id='', name=game['name'], path='', type=GameType.New, special_registry_path='', exe='', status=GameStatus.Unknown, owned=game['ownership'], activation_id=str(game['id']))) self.games_collection.extend(subscription_games) async def _parse_club_games(self): if not self.parsing_club_games: try: self.parsing_club_games = True games = await self.client.get_club_titles() club_games = [] for game in games: if "platform" in game: if game["platform"] == "PC": log.info( f"Parsed game from Club Request {game['title']}" ) club_games.append( UbisoftGame(space_id=game['spaceId'], launch_id='', install_id='', third_party_id='', name=game['title'], path='', type=GameType.New, special_registry_path='', exe='', status=GameStatus.Unknown, owned=True)) else: log.debug( f"Skipped game from Club Request for {game['platform']}: {game['spaceId']}, {game['title']}" ) self.games_collection.extend(club_games) except ApplicationError as e: log.error( f"Encountered exception while parsing club games {repr(e)}" ) raise e except Exception as e: log.error( f"Encountered exception while parsing club games {repr(e)}" ) finally: self.parsing_club_games = False else: # Wait until club games get parsed if parsing is already in progress while self.parsing_club_games: await asyncio.sleep(0.2) def _parse_local_games(self): """Parsing local files should lead to every game having a launch id. A game in the games_collection which doesn't have a launch id probably means that a game was added through the get_club_titles request but its space id was not present in configuration file and we couldn't find a matching launch id for it.""" if self.local_client.configurations_accessible(): try: configuration_data = self.local_client.read_config() p = LocalParser() games = [] for game in p.parse_games(configuration_data): games.append(game) self.games_collection.extend(games) except scanner.ScannerError as e: log.error( f"Scanner error while parsing configuration, yaml is probably corrupted {repr(e)}" ) def _parse_local_game_ownership(self): if self.local_client.ownership_accessible(): ownership_data = self.local_client.read_ownership() p = LocalParser() ownership_records = p.get_owned_local_games(ownership_data) log.info(f"Ownership Records {ownership_records}") for game in self.games_collection: if game.install_id: if int(game.install_id) in ownership_records: game.owned = True if game.launch_id: if int(game.launch_id) in ownership_records: game.owned = True def _update_games(self): self.updating_games = True self._parse_local_games() self._parse_local_game_ownership() self.updating_games = False def _update_local_games_status(self): cached_statuses = self.cached_game_statuses if cached_statuses is None: return for game in self.games_collection: try: self.game_status_notifier.update_game(game) if game.status != cached_statuses[game.install_id]: log.info( f"Game {game.name} path changed: updating status from {cached_statuses[game.install_id]} to {game.status}" ) self.update_local_game_status(game.as_local_game()) self.cached_game_statuses[game.install_id] = game.status except KeyError: self.game_status_notifier.update_game(game) ''' If a game wasn't previously in a cache then and it appears with an installed or running status it most likely means that client was just installed ''' if game.status in [GameStatus.Installed, GameStatus.Running]: self.update_local_game_status(game.as_local_game()) self.cached_game_statuses[game.install_id] = game.status if SYSTEM == System.WINDOWS: async def get_local_games(self): self._parse_local_games() local_games = [] for game in self.games_collection: self.cached_game_statuses[game.launch_id] = game.status if game.status == GameStatus.Installed or game.status == GameStatus.Running: local_games.append(game.as_local_game()) self._update_local_games_status() self.parsed_local_games = True return local_games async def _add_new_games(self, games): await self._parse_club_games() self._parse_local_game_ownership() for game in games: if game.owned: self.add_game(game.as_galaxy_game()) async def prepare_game_times_context(self, game_ids): return await self.get_playtime(game_ids) async def get_game_time(self, game_id, context): game_time = context.get(game_id) if game_time is None: raise UnknownError("Game {} not owned".format(game_id)) return game_time async def get_playtime(self, game_ids): if not self.client.is_authenticated(): raise AuthenticationRequired() games_playtime = {} blacklist = json.loads( self.persistent_cache.get('games_without_stats', '{}')) current_time = int(time.time()) for game_id in game_ids: if not self.games_collection.get(game_id): await self.get_owned_games() break for game_id in game_ids: try: expire_in = blacklist.get(game_id, 0) - current_time if expire_in > 0: log.debug( f'Cache: No game stats for {game_id}. Recheck in {expire_in}s' ) games_playtime[game_id] = GameTime(game_id, None, None) continue game = self.games_collection[game_id] if not game.space_id: games_playtime[game_id] = GameTime(game_id, None, None) continue try: response = await self.client.get_game_stats(game.space_id) except ApplicationError as err: self._game_time_import_failure(game_id, err) continue statscards = response.get('Statscards', None) if statscards is None: blacklist[ game_id] = current_time + 3600 * 24 * 14 # two weeks games_playtime[game_id] = GameTime(game_id, None, None) continue playtime, last_played = find_times(statscards, game_id) if playtime == 0: playtime = None if last_played == 0: last_played = None log.info( f'Stats for {game.name}: playtime: {playtime}, last_played: {last_played}' ) games_playtime[game_id] = GameTime(game_id, playtime, last_played) except Exception as e: log.error( f"Getting game times for game {game_id} has crashed: " + repr(e)) self._game_time_import_failure(game_id, UnknownError()) self.persistent_cache['games_without_stats'] = json.dumps(blacklist) self.push_cache() return games_playtime async def get_unlocked_challenges(self, game_id): """Challenges are a unique uplay club feature and don't directly translate to achievements""" if not self.client.is_authenticated(): raise AuthenticationRequired() for game in self.games_collection: if game.space_id == game_id or game.launch_id == game_id: if not game.space_id: return [] challenges = await self.client.get_challenges(game.space_id) return [ Achievement(achievement_id=challenge["id"], achievement_name=challenge["name"], unlock_time=int( datetime.datetime.timestamp( dateutil.parser.parse( challenge["completionDate"])))) for challenge in challenges["actions"] if challenge["isCompleted"] and not challenge["isBadge"] ] if SYSTEM == System.WINDOWS: async def launch_game(self, game_id): if not self.parsed_local_games: await self.get_local_games() elif not self.user_can_perform_actions(): return for game in self.games_collection.get_local_games(): if (game.space_id == game_id or game.install_id == game_id or game.launch_id == game_id) and game.status == GameStatus.Installed: if game.type == GameType.Steam: if is_steam_installed(): url = f"start steam://rungameid/{game.third_party_id}" else: url = f"start uplay://open/game/{game.launch_id}" elif game.type == GameType.New or game.type == GameType.Legacy: log.debug('Launching game') self.game_status_notifier._legacy_game_launched = True url = f"start uplay://launch/{game.launch_id}" else: log.error(f"Unsupported game type {game.name}") self.open_uplay_client() return log.info( f"Launching game '{game.name}' by protocol: [{url}]") subprocess.Popen(url, shell=True) self.reset_tick_count() return for game in self.games_collection: if (game.space_id == game_id or game.install_id == game_id) and game.status in [ GameStatus.NotInstalled, GameStatus.Unknown ]: log.warning("Game is not installed, installing") return await self.install_game(game_id) log.info("Failed to launch game, launching client instead.") self.open_uplay_client() async def activate_game(self, activation_id): if not await self.client.activate_game(activation_id): log.info(f"Couldnt activate game with id {activation_id}") return log.info(f"Activated game with id {activation_id}") timeout = time.time() + 3 while timeout >= time.time(): if self.local_client.ownership_changed(): # Will refresh informations in collection about the game await self.get_owned_games() await asyncio.sleep(0.1) if SYSTEM == System.WINDOWS: async def install_game(self, game_id, retry=False): log.debug(self.games_collection) if not self.user_can_perform_actions(): return for game in self.games_collection: game_ids = [game.space_id, game.install_id, game.launch_id] if (game_id in game_ids) and game.owned and game.status in [ GameStatus.NotInstalled, GameStatus.Unknown ]: if game.install_id: log.info(f"Installing game: {game_id}, {game}") subprocess.Popen( f"start uplay://install/{game.install_id}", shell=True) return if (game_id in game_ids) and game.status == GameStatus.Installed: log.warning("Game already installed, launching") return await self.launch_game(game_id) if (game_id in game_ids ) and not game.owned and game.activation_id and not retry: log.warning("Activating game from subscription") if not self.local_client.is_running(): self.open_uplay_client() timeout = time.time() + 10 while not self.local_client.is_running( ) and time.time() <= timeout: await asyncio.sleep(0.1) await self.activate_game(game.activation_id) asyncio.create_task( self.install_game(game_id=game_id, retry=True)) # if launch_id is not known, try to launch local client instead self.open_uplay_client() log.info( f"Did not found game with game_id: {game_id}, proper launch_id and NotInstalled status, launching client." ) if SYSTEM == System.WINDOWS: async def uninstall_game(self, game_id): if not self.user_can_perform_actions(): return for game in self.games_collection.get_local_games(): if (game.space_id == game_id or game.launch_id == game_id) and game.status == GameStatus.Installed: subprocess.Popen( f"start uplay://uninstall/{game.launch_id}", shell=True) return self.open_uplay_client() log.info( f"Did not found game with game_id: {game_id}, proper launch_id and Installed status, launching client." ) def user_can_perform_actions(self): if not self.local_client.is_installed: self.open_uplay_browser() return False if not self.local_client.was_user_logged_in: self.open_uplay_client() return False return True def open_uplay_client(self): subprocess.Popen("start uplay://", shell=True) def open_uplay_browser(self): url = 'https://uplay.ubisoft.com' log.info(f"Opening uplay website: {url}") webbrowser.open(url, autoraise=True) def refresh_game_statuses(self): if not self.local_client.was_user_logged_in: return statuses = self.game_status_notifier.statuses new_games = [] for game in self.games_collection: try: if statuses[ game. install_id] == GameStatus.Installed and game.status in [ GameStatus.NotInstalled, GameStatus.Unknown ]: log.info( f"updating status for {game.name} to installed from not installed" ) game.status = GameStatus.Installed self.update_local_game_status(game.as_local_game()) elif statuses[ game. install_id] == GameStatus.Installed and game.status == GameStatus.Running: log.info( f"updating status for {game.name} to installed from running" ) game.status = GameStatus.Installed self.update_local_game_status(game.as_local_game()) asyncio.create_task(self.prevent_uplay_from_showing()) elif statuses[ game. install_id] == GameStatus.Running and game.status != GameStatus.Running: log.info(f"updating status for {game.name} to running") game.status = GameStatus.Running self.update_local_game_status(game.as_local_game()) elif statuses[game.install_id] in [ GameStatus.NotInstalled, GameStatus.Unknown ] and game.status not in [ GameStatus.NotInstalled, GameStatus.Unknown ]: log.info( f"updating status for {game.name} to not installed") game.status = GameStatus.NotInstalled self.update_local_game_status(game.as_local_game()) except KeyError: continue if self.owned_games_sent and not game.considered_for_sending: game.considered_for_sending = True new_games.append(game) if new_games: asyncio.create_task(self._add_new_games(new_games)) async def get_friends(self): friends = await self.client.get_friends() return [ FriendInfo(user_id=friend["pid"], user_name=friend["nameOnPlatform"]) for friend in friends["friends"] ] async def get_subscriptions(self) -> List[Subscription]: sub_status = await self.client.get_subscription() sub_status = True if sub_status else False return [ Subscription( subscription_name="Uplay+", end_time=None, owned=sub_status, subscription_discovery=SubscriptionDiscovery.AUTOMATIC) ] async def prepare_subscription_games_context( self, subscription_names: List[str]) -> Any: sub_games_response = await self.client.get_subscription() if sub_games_response: return [ SubscriptionGame(game_title=game['name'], game_id=str(game['uplayGameId'])) for game in sub_games_response["games"] ] return None async def get_subscription_games( self, subscription_name: str, context: Any) -> AsyncGenerator[List[SubscriptionGame], None]: yield context if SYSTEM == System.WINDOWS: async def launch_platform_client(self): if self.local_client.is_running(): log.info( "Launch platform client called but Uplay is already running" ) return url = "start uplay://" subprocess.Popen(url, shell=True) # Uplay tries to get focus a couple of times when being launched end_time = time.time() + 15 while time.time() <= end_time: await self.prevent_uplay_from_showing(kill_attempt=False) await asyncio.sleep(0.05) if SYSTEM == System.WINDOWS: async def shutdown_platform_client(self): if self.local_client.is_installed: subprocess.Popen("taskkill.exe /im \"upc.exe\"", shell=True) if SYSTEM == System.WINDOWS: async def prevent_uplay_from_showing(self, kill_attempt=True): if not self.local_client.is_installed: log.info("Local client not installed") return client_popup_wait_time = 5 check_frequency_delay = 0.02 end_time = time.time() + client_popup_wait_time hwnd = ctypes.windll.user32.FindWindowW(None, "Uplay") while not ctypes.windll.user32.IsWindowVisible(hwnd): if time.time() >= end_time: log.info("Timed out post close game uplay popup") break hwnd = ctypes.windll.user32.FindWindowW(None, "Uplay") await asyncio.sleep(check_frequency_delay) if kill_attempt: await self.shutdown_platform_client() else: ctypes.windll.user32.SetForegroundWindow(hwnd) ctypes.windll.user32.CloseWindow(hwnd) if SYSTEM == System.WINDOWS: async def prepare_game_library_settings_context(self, game_ids): if self.local_client.settings_accessible(): library_context = {} settings_data = self.local_client.read_settings() parser = LocalParser() favorite_games, hidden_games = parser.get_game_tags( settings_data) for game_id in game_ids: try: game = self.games_collection[game_id] except KeyError: continue library_context[game_id] = { 'favorite': game.launch_id in favorite_games, 'hidden': game.launch_id in hidden_games } return library_context return None async def get_game_library_settings(self, game_id, context): log.debug(f"Context {context}") if not context: # Unable to retrieve context return GameLibrarySettings(game_id, None, None) game_library_settings = context.get(game_id) if game_library_settings is None: # Able to retrieve context but game is not in its values -> It doesnt have any tags or hidden status set return GameLibrarySettings(game_id, [], False) return GameLibrarySettings( game_id, ['favorite'] if game_library_settings['favorite'] else [], game_library_settings['hidden']) def reset_tick_count(self): # Resetting tick count ensures that certain operations performed on tick will be made with a known delay. self.tick_count = 0 def tick(self): loop = asyncio.get_event_loop() if SYSTEM == System.WINDOWS: self.tick_count += 1 if self.tick_count % 1 == 0: self.refresh_game_statuses() if self.tick_count % 5 == 0: self.game_status_notifier.launcher_log_path = self.local_client.launcher_log_path if self.tick_count % 9 == 0: self._update_local_games_status() if self.local_client.ownership_changed(): if not self.updating_games: log.info( 'Ownership file has been changed or created. Reparsing.' ) loop.run_in_executor(None, self._update_games) return async def shutdown(self): log.info("Plugin shutdown.") await self.client.close()
class BNetPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.Battlenet, version, reader, writer, token) self.local_client = LocalClient(self._update_statuses) self.authentication_client = AuthenticatedHttpClient(self) self.backend_client = BackendClient(self, self.authentication_client) self.owned_games_cache = [] self.watched_running_games = set() self.local_games_called = False async def _notify_about_game_stop(self, game, starting_timeout): if not self.local_games_called: return id_to_watch = game.info.id if id_to_watch in self.watched_running_games: log.debug(f'Game {id_to_watch} is already watched. Skipping') return try: self.watched_running_games.add(id_to_watch) await asyncio.sleep(starting_timeout) ProcessProvider().update_games_processes([game]) log.info(f'Setuping process watcher for {game._processes}') loop = asyncio.get_event_loop() await loop.run_in_executor(None, game.wait_until_game_stops) finally: self.update_local_game_status( LocalGame(id_to_watch, LocalGameState.Installed)) self.watched_running_games.remove(id_to_watch) def _update_statuses(self, refreshed_games, previous_games): if not self.local_games_called: return for blizz_id, refr in refreshed_games.items(): prev = previous_games.get(blizz_id, None) if prev is None: if refr.playable: log.debug('Detected playable game') state = LocalGameState.Installed else: log.debug('Detected installation begin') state = LocalGameState.None_ elif refr.playable and not prev.playable: log.debug('Detected playable game') state = LocalGameState.Installed elif refr.last_played != prev.last_played: log.debug('Detected launched game') state = LocalGameState.Installed | LocalGameState.Running asyncio.create_task(self._notify_about_game_stop(refr, 5)) else: continue log.info(f'Changing game {blizz_id} state to {state}') self.update_local_game_status(LocalGame(blizz_id, state)) for blizz_id, prev in previous_games.items(): refr = refreshed_games.get(blizz_id, None) if refr is None: log.debug('Detected uninstalled game') state = LocalGameState.None_ self.update_local_game_status(LocalGame(blizz_id, state)) def log_out(self): if self.backend_client: asyncio.create_task(self.authentication_client.shutdown()) self.authentication_client.user_details = None self.owned_games_cache = [] async def open_battlenet_browser(self): url = self.authentication_client.blizzard_battlenet_download_url log.info(f'Opening battle.net website: {url}') loop = asyncio.get_running_loop() await loop.run_in_executor( None, lambda x: webbrowser.open(x, autoraise=True), url) async def install_game(self, game_id): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() installed_game = self.local_client.get_installed_games().get( game_id, None) if installed_game and os.access(installed_game.install_path, os.F_OK): log.warning( "Received install command on an already installed game") return await self.launch_game(game_id) if game_id in Blizzard.legacy_game_ids: if SYSTEM == pf.WINDOWS: platform = 'windows' elif SYSTEM == pf.MACOS: platform = 'macos' webbrowser.open( f"https://www.blizzard.com/download/confirmation?platform={platform}&locale=enUS&version=LIVE&id={game_id}" ) return try: self.local_client.refresh() log.info(f'Installing game of id {game_id}') self.local_client.install_game(game_id) except ClientNotInstalledError as e: log.warning(e) await self.open_battlenet_browser() except Exception as e: log.exception(f"Installing game {game_id} failed: {e}") def _open_battlenet_at_id(self, game_id): try: self.local_client.refresh() self.local_client.open_battlenet(game_id) except Exception as e: log.exception( f"Opening battlenet client on specific game_id {game_id} failed {e}" ) try: self.local_client.open_battlenet() except Exception as e: log.exception(f"Opening battlenet client failed {e}") async def uninstall_game(self, game_id): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() if game_id == 'wow_classic': # attempting to uninstall classic wow through protocol gives you a message that the game cannot # be uninstalled through protocol and you should use battle.net return self._open_battlenet_at_id(game_id) if SYSTEM == pf.MACOS: self._open_battlenet_at_id(game_id) else: try: installed_game = self.local_client.get_installed_games().get( game_id, None) if installed_game is None or not os.access( installed_game.install_path, os.F_OK): log.error(f'Cannot uninstall {Blizzard[game_id].uid}') self.update_local_game_status( LocalGame(game_id, LocalGameState.None_)) return if not isinstance(installed_game.info, ClassicGame): if self.local_client.uninstaller is None: raise FileNotFoundError('Uninstaller not found') uninstall_tag = installed_game.uninstall_tag client_lang = self.local_client.config_parser.locale_language self.local_client.uninstaller.uninstall_game( installed_game, uninstall_tag, client_lang) except Exception as e: log.exception(f'Uninstalling game {game_id} failed: {e}') async def launch_game(self, game_id): if not self.local_games_called: await self.get_local_games() try: if self.local_client.get_installed_games() is None: log.error(f'Launching game that is not installed: {game_id}') return await self.install_game(game_id) game = self.local_client.get_installed_games().get(game_id, None) if game is None: log.error(f'Launching game that is not installed: {game_id}') return await self.install_game(game_id) if isinstance(game.info, ClassicGame): log.info( f'Launching game of id: {game_id}, {game} at path {os.path.join(game.install_path, game.info.exe)}' ) if SYSTEM == pf.WINDOWS: subprocess.Popen( os.path.join(game.install_path, game.info.exe)) elif SYSTEM == pf.MACOS: if not game.info.bundle_id: log.warning( f"{game.name} has no bundle id, help by providing us bundle id of this game" ) subprocess.Popen(['open', '-b', game.info.bundle_id]) self.update_local_game_status( LocalGame( game_id, LocalGameState.Installed | LocalGameState.Running)) asyncio.create_task(self._notify_about_game_stop(game, 6)) return self.local_client.refresh() log.info(f'Launching game of id: {game_id}, {game}') await self.local_client.launch_game(game, wait_sec=60) self.update_local_game_status( LocalGame(game_id, LocalGameState.Installed | LocalGameState.Running)) self.local_client.close_window() asyncio.create_task(self._notify_about_game_stop(game, 3)) except ClientNotInstalledError as e: log.warning(e) await self.open_battlenet_browser() except TimeoutError as e: log.warning(str(e)) except Exception as e: log.exception(f"Launching game {game_id} failed: {e}") async def authenticate(self, stored_credentials=None): try: if stored_credentials: auth_data = self.authentication_client.process_stored_credentials( stored_credentials) try: await self.authentication_client.create_session() await self.backend_client.refresh_cookies() auth_status = await self.backend_client.validate_access_token( auth_data.access_token) except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e: raise e except Exception: raise InvalidCredentials() if self.authentication_client.validate_auth_status( auth_status): self.authentication_client.user_details = await self.backend_client.get_user_info( ) return self.authentication_client.parse_user_details() else: return self.authentication_client.authenticate_using_login() except Exception as e: raise e async def pass_login_credentials(self, step, credentials, cookies): if "logout&app=oauth" in credentials['end_uri']: # 2fa expired, repeat authentication return self.authentication_client.authenticate_using_login() if self.authentication_client.attempted_to_set_battle_tag: self.authentication_client.user_details = await self.backend_client.get_user_info( ) return self.authentication_client.parse_auth_after_setting_battletag( ) cookie_jar = self.authentication_client.parse_cookies(cookies) auth_data = await self.authentication_client.get_auth_data_login( cookie_jar, credentials) try: await self.authentication_client.create_session() await self.backend_client.refresh_cookies() except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e: raise e except Exception: raise InvalidCredentials() auth_status = await self.backend_client.validate_access_token( auth_data.access_token) if not ("authorities" in auth_status and "IS_AUTHENTICATED_FULLY" in auth_status["authorities"]): raise InvalidCredentials() self.authentication_client.user_details = await self.backend_client.get_user_info( ) self.authentication_client.set_credentials() return self.authentication_client.parse_battletag() async def get_owned_games(self): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() def _parse_classic_games(classic_games): for classic_game in classic_games["classicGames"]: log.info(f"looking for {classic_game} in classic games") try: blizzard_game = Blizzard[ classic_game["localizedGameName"].replace( u'\xa0', ' ')] log.info(f"match! {blizzard_game}") classic_game["titleId"] = blizzard_game.uid classic_game["gameAccountStatus"] = "Good" except KeyError: continue return classic_games def _get_not_added_free_games(owned_games): owned_games_ids = [] for game in owned_games: if "titleId" in game: owned_games_ids.append(str(game["titleId"])) return [{ "titleId": game.blizzard_id, "localizedGameName": game.name, "gameAccountStatus": "Free" } for game in Blizzard.free_games if game.blizzard_id not in owned_games_ids] try: games = await self.backend_client.get_owned_games() classic_games = _parse_classic_games( await self.backend_client.get_owned_classic_games()) owned_games = games["gameAccounts"] + classic_games["classicGames"] # Add wow classic if retail wow is present in owned games for owned_game in owned_games.copy(): if 'titleId' in owned_game: if owned_game['titleId'] == 5730135: owned_games.append({ 'titleId': 'wow_classic', 'localizedGameName': 'World of Warcraft Classic', 'gameAccountStatus': owned_game['gameAccountStatus'] }) free_games_to_add = _get_not_added_free_games(owned_games) owned_games += free_games_to_add self.owned_games_cache = owned_games return [ Game( str(game["titleId"]), game["localizedGameName"], [], LicenseInfo(License_Map[game["gameAccountStatus"]]), ) for game in self.owned_games_cache if "titleId" in game ] except Exception as e: log.exception(f"failed to get owned games: {repr(e)}") raise async def get_local_games(self): timeout = time.time() + 2 try: translated_installed_games = [] while not self.local_client.games_finished_parsing(): await asyncio.sleep(0.1) if time.time() >= timeout: break running_games = self.local_client.get_running_games() installed_games = self.local_client.get_installed_games() log.info(f"Installed games {installed_games.items()}") log.info(f"Running games {running_games}") for id_, game in installed_games.items(): if game.playable: state = LocalGameState.Installed if id_ in running_games: state |= LocalGameState.Running else: state = LocalGameState.None_ translated_installed_games.append(LocalGame(id_, state)) self.local_client.installed_games_cache = installed_games return translated_installed_games except Exception as e: log.exception(f"failed to get local games: {str(e)}") raise finally: self.local_games_called = True async def get_game_time(self, game_id, context): game_time_minutes = None if game_id == "5272175": game_time_minutes = await self._get_overwatch_time() log.debug( f"Gametime for Overwatch is {game_time_minutes} minutes.") return GameTime(game_id, game_time_minutes, None) async def _get_overwatch_time(self) -> Union[None, int]: log.debug("Fetching playtime for Overwatch...") player_data = await self.backend_client.get_ow_player_data() if 'message' in player_data: # user not found... unfortunately no 404 status code is returned :/ log.error('No Overwatch profile found.') return None if player_data['private'] == True: log.info('Unable to get data as Overwatch profile is private.') return None qp_time = player_data['playtime']['quickplay'] if qp_time is None: # user has not played quick play return 0 if qp_time.count(':') == 1: # minutes and seconds match = re.search('(?:(?P<m>\\d+):)(?P<s>\\d+)', qp_time) if match: return int(match.group('m')) elif qp_time.count(':') == 2: # hours, minutes and seconds match = re.search('(?:(?P<h>\\d+):)(?P<m>\\d+)', qp_time) if match: return int(match.group('h')) * 60 + int(match.group('m')) raise UnknownBackendResponse( f'Unknown Overwatch API playtime format: {qp_time}') async def _get_wow_achievements(self): achievements = [] try: characters_data = await self.backend_client.get_wow_character_data( ) characters_data = characters_data["characters"] wow_character_data = await asyncio.gather( *[ self.backend_client.get_wow_character_achievements( character["realm"], character["name"]) for character in characters_data ], return_exceptions=True, ) for data in wow_character_data: if isinstance(data, requests.Timeout) or isinstance( data, requests.ConnectionError): raise data wow_achievement_data = [ list( zip( data["achievements"]["achievementsCompleted"], data["achievements"]["achievementsCompletedTimestamp"], )) for data in wow_character_data if type(data) is dict ] already_in = set() for char_ach in wow_achievement_data: for ach in char_ach: if ach[0] not in already_in: achievements.append( Achievement(achievement_id=ach[0], unlock_time=int(ach[1] / 1000))) already_in.add(ach[0]) except (AccessTokenExpired, BackendError) as e: log.exception(str(e)) with open('wow.json', 'w') as f: f.write(json.dumps(achievements, cls=DataclassJSONEncoder)) return achievements async def _get_sc2_achievements(self): account_data = await self.backend_client.get_sc2_player_data( self.authentication_client.user_details["id"]) # TODO what if more sc2 accounts? assert len(account_data) == 1 account_data = account_data[0] profile_data = await self.backend_client.get_sc2_profile_data( account_data["regionId"], account_data["realmId"], account_data["profileId"]) sc2_achievement_data = [ Achievement(achievement_id=achievement["achievementId"], unlock_time=achievement["completionDate"]) for achievement in profile_data["earnedAchievements"] if achievement["isComplete"] ] with open('sc2.json', 'w') as f: f.write(json.dumps(sc2_achievement_data, cls=DataclassJSONEncoder)) return sc2_achievement_data # async def get_unlocked_achievements(self, game_id): # if not self.website_client.is_authenticated(): # raise AuthenticationRequired() # try: # if game_id == "21298": # return await self._get_sc2_achievements() # elif game_id == "5730135": # return await self._get_wow_achievements() # else: # return [] # except requests.Timeout: # raise BackendTimeout() # except requests.ConnectionError: # raise NetworkError() # except Exception as e: # log.exception(str(e)) # return [] async def launch_platform_client(self): if self.local_client.is_running(): log.info( "Launch platform client called but client is already running") return self.local_client.open_battlenet() await self.local_client.prevent_battlenet_from_showing() async def shutdown_platform_client(self): await self.local_client.shutdown_platform_client() async def shutdown(self): log.info("Plugin shutdown.") await self.authentication_client.shutdown()