class BNetPlugin(Plugin): PRODUCT_DB_PATH = pathlib.Path(AGENT_PATH) / 'product.db' CONFIG_PATH = CONFIG_PATH def __init__(self, reader, writer, token): super().__init__(Platform.Battlenet, version, reader, writer, token) log.info(f"Starting Battle.net plugin, version {version}") self.bnet_client = None self.local_client = LocalClient() self.authentication_client = AuthenticatedHttpClient(self) self.backend_client = BackendClient(self, self.authentication_client) self.social_features = SocialFeatures(self.authentication_client) self.error_state = False self.running_task = None self.database_parser = None self.config_parser = None self.uninstaller = None self.owned_games_cache = [] self._classic_games_thread = None self._battlenet_games_thread = None self._installed_battlenet_games = {} self._installed_battlenet_games_lock = Lock() self.installed_games = self._parse_local_data() self.watched_running_games = set() self.notifications_enabled = False loop = asyncio.get_event_loop() loop.create_task(self._register_local_data_watcher()) async def _register_local_data_watcher(self): async def ping(event, interval): while True: await asyncio.sleep(interval) if not self.watched_running_games: if not event.is_set(): event.set() parse_local_data_event = asyncio.Event() FileWatcher(self.CONFIG_PATH, parse_local_data_event, interval=1) FileWatcher(self.PRODUCT_DB_PATH, parse_local_data_event, interval=2.5) asyncio.create_task(ping(parse_local_data_event, 30)) while True: await parse_local_data_event.wait() refreshed_games = self._parse_local_data() if not self.notifications_enabled: self._update_statuses(refreshed_games, self.installed_games) self.installed_games = refreshed_games parse_local_data_event.clear() async def _notify_about_game_stop(self, game, starting_timeout): 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): 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 _load_local_files(self): try: product_db = load_product_db(self.PRODUCT_DB_PATH) self.database_parser = DatabaseParser(product_db) except FileNotFoundError as e: log.warning(f"product.db not found: {repr(e)}") return False except WindowsError as e: # 5 WindowsError access denied if e.winerror == 5: log.warning(f"product.db not accessible: {repr(e)}") self.config_parser = ConfigParser(None) return False else: raise () except OSError as e: if e.errno == errno.EACCES: log.warning(f"product.db not accessible: {repr(e)}") self.config_parser = ConfigParser(None) return False else: raise () else: if self.local_client.is_installed != self.database_parser.battlenet_present: self.local_client.refresh() try: config = load_config(self.CONFIG_PATH) self.config_parser = ConfigParser(config) except FileNotFoundError as e: log.warning(f"config file not found: {repr(e)}") self.config_parser = ConfigParser(None) return False except WindowsError as e: # 5 WindowsError access denied if e.winerror == 5: log.warning(f"config file not accessible: {repr(e)}") self.config_parser = ConfigParser(None) return False else: raise () except OSError as e: if e.errno == errno.EACCES: log.warning(f"config file not accessible: {repr(e)}") self.config_parser = ConfigParser(None) return False else: raise () return True def _get_battlenet_installed_games(self): def _add_battlenet_game(config_game, db_game): if config_game.uninstall_tag != db_game.uninstall_tag: return None try: blizzard_game = Blizzard[config_game.uid] except KeyError: log.warning(f'[{config_game.uid}] is not known blizzard game. Skipping') return None try: log.info(f"Adding {blizzard_game.blizzard_id} {blizzard_game.name} to installed games") return InstalledGame( blizzard_game, config_game.uninstall_tag, db_game.version, config_game.last_played, db_game.install_path, db_game.playable ) except FileNotFoundError as e: log.warning(str(e) + '. Probably outdated product.db after uninstall. Skipping') return None games = {} for db_game in self.database_parser.games: for config_game in self.config_parser.games: installed_game = _add_battlenet_game(config_game, db_game) if installed_game: games[installed_game.info.id] = installed_game self._installed_battlenet_games_lock.acquire() self._installed_battlenet_games = games self._installed_battlenet_games_lock.release() def _parse_local_data(self): """Game is considered as installed when present in both config and product.db""" games = {} # give threads 4 seconds to finish join_timeout = 4 if not self._classic_games_thread or not self._classic_games_thread.isAlive(): self._classic_games_thread = Thread(target=self.local_client.find_classic_games, daemon=True) self._classic_games_thread.start() log.info("Started classic games thread") if not self._load_local_files(): self._classic_games_thread.join(join_timeout) if not self.local_client.classics_lock.acquire(False): return [] else: installed_classics = self.local_client.installed_classics self.local_client.classics_lock.release() return installed_classics try: if SYSTEM == pf.WINDOWS and self.uninstaller is None: uninstaller_path = pathlib.Path(AGENT_PATH) / 'Blizzard Uninstaller.exe' self.uninstaller = Uninstaller(uninstaller_path) except FileNotFoundError as e: log.warning('uninstaller not found' + str(e)) try: if self.local_client.is_installed != self.database_parser.battlenet_present: self.local_client.refresh() log.info(f"Games found in db {self.database_parser.games}") log.info(f"Games found in config {self.config_parser.games}") if not self._battlenet_games_thread or not self._battlenet_games_thread.isAlive(): self._battlenet_games_thread = Thread(target=self._get_battlenet_installed_games, daemon=True) self._battlenet_games_thread.start() log.info("Started classic games thread") except Exception as e: log.exception(str(e)) finally: self._classic_games_thread.join(join_timeout) self._battlenet_games_thread.join(join_timeout) if self.local_client.classics_lock.acquire(False): games = self.local_client.installed_classics self.local_client.classics_lock.release() if self._installed_battlenet_games_lock.acquire(False): games = {**self._installed_battlenet_games, **games} self._installed_battlenet_games_lock.release() return games 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 = f"https://www.blizzard.com/apps/battle.net/desktop" 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.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.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.uninstaller is None: raise FileNotFoundError('Uninstaller not found') uninstall_tag = installed_game.uninstall_tag client_lang = self.config_parser.locale_language self.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.authentication_client.is_authenticated(): raise AuthenticationRequired() try: if self.installed_games is None: log.error(f'Launching game that is not installed: {game_id}') return await self.install_game(game_id) game = self.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_friends(self): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() friends_list = await self.social_features.get_friends() return [FriendInfo(user_id=friend.id.low, user_name='') for friend in friends_list] 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 log.info(f"Owned games {owned_games} with free games") 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): try: local_games = [] running_games = ProcessProvider().update_games_processes(self.installed_games.values()) log.info(f"Installed games {self.installed_games.items()}") log.info(f"Running games {running_games}") for id_, game in self.installed_games.items(): if game.playable: state = LocalGameState.Installed if id_ in running_games: state |= LocalGameState.Running else: state = LocalGameState.None_ local_games.append(LocalGame(id_, state)) return local_games except Exception as e: log.exception(f"failed to get local games: {str(e)}") raise finally: self.enable_notifications = True 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 _tick_runner(self): if not self.bnet_client: return try: self.error_state = await self.bnet_client.tick() except Exception as e: self.error_state = True log.exception(f"error state: {str(e)}") raise def tick(self): if not self.error_state and (not self.running_task or self.running_task.done()): self.running_task = asyncio.create_task(self._tick_runner()) elif self.error_state: sys.exit(1) def shutdown(self): log.info("Plugin shutdown.") asyncio.create_task(self.authentication_client.shutdown())
class RockstarPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.RiotGames, __version__, reader, writer, token) self.games_cache = games_cache self._http_client = AuthenticatedHttpClient(self.store_credentials) self._local_client = LocalClient() self.total_games_cache = self.create_total_games_cache() self.friends_cache = [] self.owned_games_cache = [] self.local_games_cache = {} self.running_games_pids = {} self.game_is_loading = True self.checking_for_new_games = False self.updating_game_statuses = False self.buffer = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH) ctypes.windll.shell32.SHGetFolderPathW(None, 5, None, 0, self.buffer) self.documents_location = self.buffer.value def is_authenticated(self): return self._http_client.is_authenticated() async def authenticate(self, stored_credentials=None): self._http_client.create_session(stored_credentials) if not stored_credentials: return NextStep("web_session", AUTH_PARAMS) try: log.info("INFO: The credentials were successfully obtained.") cookies = pickle.loads( bytes.fromhex(stored_credentials['session_object'])).cookies log.debug("ROCKSTAR_COOKIES_FROM_HEX: " + str(cookies)) for cookie in cookies: cookie_object = { "name": cookie.name, "value": cookie.value, "domain": cookie.domain, "path": cookie.path } self._http_client.update_cookie(cookie_object) self._http_client.set_current_auth_token( stored_credentials['current_auth_token']) log.info( "INFO: The stored credentials were successfully parsed. Beginning authentication..." ) user = await self._http_client.authenticate() return Authentication(user_id=user['rockstar_id'], user_name=user['display_name']) except Exception as e: log.warning( "ROCKSTAR_AUTH_WARNING: The exception " + repr(e) + " was thrown, presumably because of " "outdated credentials. Attempting to get new credentials...") self._http_client.set_auth_lost_callback(self.lost_authentication) try: user = await self._http_client.authenticate() return Authentication(user_id=user['rockstar_id'], user_name=user['display_name']) except Exception as e: log.error( "ROCKSTAR_AUTH_FAILURE: Something went terribly wrong with the re-authentication. " + repr(e)) log.exception("ROCKSTAR_STACK_TRACE") raise InvalidCredentials() async def pass_login_credentials(self, step, credentials, cookies): log.debug("ROCKSTAR_COOKIE_LIST: " + str(cookies)) for cookie in cookies: if cookie['name'] == "ScAuthTokenData": self._http_client.set_current_auth_token(cookie['value']) cookie_object = { "name": cookie['name'], "value": cookie['value'], "domain": cookie['domain'], "path": cookie['path'] } self._http_client.update_cookie(cookie_object) try: user = await self._http_client.authenticate() except Exception as e: log.error(repr(e)) raise InvalidCredentials() return Authentication(user_id=user["rockstar_id"], user_name=user["display_name"]) async def shutdown(self): await self._http_client.close() def create_total_games_cache(self): cache = [] for title_id in list(games_cache): cache.append(self.create_game_from_title_id(title_id)) return cache async def get_friends(self): # This method is currently causing the plugin to crash after its second call. I am unsure of why this is # happening, but it needs to be fixed. # NOTE: This will return a list of type FriendInfo. # The Social Club website returns a list of the current user's friends through the url # https://scapi.rockstargames.com/friends/getFriendsFiltered?onlineService=sc&nickname=&pageIndex=0&pageSize=30. # The nickname URL parameter is left blank because the website instead uses the bearer token to get the correct # information. The last two parameters are of great importance, however. The parameter pageSize determines the # number of friends given on that page's list, while pageIndex keeps track of the page that the information is # on. The maximum number for pageSize is 30, so that is what we will use to cut down the number of HTTP # requests. # We first need to get the number of friends. url = ( "https://scapi.rockstargames.com/friends/getFriendsFiltered?onlineService=sc&nickname=&" "pageIndex=0&pageSize=30") current_page = await self._http_client.get_json_from_request_strict(url ) log.debug("ROCKSTAR_FRIENDS_REQUEST: " + str(current_page)) num_friends = current_page['rockstarAccountList']['totalFriends'] num_pages_required = num_friends / 30 if num_friends % 30 != 0 else ( num_friends / 30) - 1 # Now, we need to get the information about the friends. friends_list = current_page['rockstarAccountList']['rockstarAccounts'] return_list = [] for i in range(0, len(friends_list)): friend = FriendInfo(friends_list[i]['rockstarId'], friends_list[i]['displayName']) return_list.append(FriendInfo) for cached_friend in self.friends_cache: if cached_friend.user_id == friend.user_id: break else: self.friends_cache.append(friend) self.add_friend(friend) log.debug("ROCKSTAR_FRIEND: Found " + friend.user_name + " (Rockstar ID: " + str(friend.user_id) + ")") # The first page is finished, but now we need to work on any remaining pages. if num_pages_required > 0: for i in range(1, int(num_pages_required + 1)): url = ( "https://scapi.rockstargames.com/friends/getFriendsFiltered?onlineService=sc&nickname=&" "pageIndex=" + str(i) + "&pageSize=30") return_list.append(friend for friend in await self._get_friends(url)) return return_list async def _get_friends(self, url): current_page = self._http_client.get_json_from_request_strict(url) friends_list = current_page['rockstarAccountList']['rockstarAccounts'] return_list = [] for i in range(0, len(friends_list)): friend = FriendInfo(friends_list[i]['rockstarId'], friends_list[i]['displayName']) return_list.append(FriendInfo) for cached_friend in self.friends_cache: if cached_friend.user_id == friend.user_id: break else: # An else-statement occurs after a for-statement if the latter finishes WITHOUT breaking. self.friends_cache.append(friend) self.add_friend(friend) log.debug("ROCKSTAR_FRIEND: Found " + friend.user_name + " (Rockstar ID: " + str(friend.user_id) + ")") return return_list async def get_owned_games(self): # Here is the actual implementation of getting the user's owned games: # -Get the list of games_played from rockstargames.com/auth/get-user.json. # -If possible, use the launcher log to confirm which games are actual launcher games and which are # Steam/Retail games. # -If it is not possible to use the launcher log, then just use the list provided by the website. if not self.is_authenticated(): raise AuthenticationRequired() # Get the list of games_played from https://www.rockstargames.com/auth/get-user.json. owned_title_ids = [] online_check_success = True try: played_games = await self._http_client.get_played_games() for game in played_games: title_id = get_game_title_id_from_online_title_id(game) owned_title_ids.append(title_id) log.debug("ROCKSTAR_ONLINE_GAME: Found played game " + title_id + "!") except Exception as e: log.error( "ROCKSTAR_PLAYED_GAMES_ERROR: The exception " + repr(e) + " was thrown when attempting to get the" " user's played games online. Falling back to log file check..." ) online_check_success = False # The log is in the Documents folder. current_log_count = 0 log_file = None log_file_append = "" # The Rockstar Games Launcher generates 10 log files before deleting them in a FIFO fashion. Old log files are # given a number ranging from 1 to 9 in their name. In case the first log file does not have all of the games, # we need to check the other log files, if possible. while current_log_count < 10: try: if current_log_count != 0: log_file_append = ".0" + str(current_log_count) log_file = os.path.join( self.documents_location, "Rockstar Games\\Launcher\\launcher" + log_file_append + ".log") log.debug("ROCKSTAR_LOG_LOCATION: Checking the file " + log_file + "...") owned_title_ids = await self.parse_log_file( log_file, owned_title_ids, online_check_success) break except NoGamesInLogException: log.warning( "ROCKSTAR_LOG_WARNING: There are no owned games listed in " + str(log_file) + ". Moving to " "the next log file...") current_log_count += 1 except NoLogFoundException: log.warning( "ROCKSTAR_LAST_LOG_REACHED: There are no more log files that can be found and/or read " "from. Assuming that the online list is correct...") break except Exception: # This occurs after ROCKSTAR_LOG_ERROR. break if current_log_count == 10: log.warning( "ROCKSTAR_LAST_LOG_REACHED: There are no more log files that can be found and/or read " "from. Assuming that the online list is correct...") remove_all = False if len(self.owned_games_cache) == 0: remove_all = True for title_id in owned_title_ids: game = self.create_game_from_title_id(title_id) if game not in self.owned_games_cache: log.debug("ROCKSTAR_ADD_GAME: Adding " + title_id + " to owned games cache...") self.add_game(game) self.owned_games_cache.append(game) if remove_all is True: for key, value in games_cache.items(): if key not in owned_title_ids: log.debug("ROCKSTAR_REMOVE_GAME: Removing " + key + " from owned games cache...") self.remove_game(value['rosTitleId']) else: for game in self.owned_games_cache: if get_game_title_id_from_ros_title_id( game.game_id) not in owned_title_ids: log.debug( "ROCKSTAR_REMOVE_GAME: Removing " + get_game_title_id_from_ros_title_id(game.game_id) + " from owned games cache...") self.remove_game(game.game_id) self.owned_games_cache.remove(game) return self.owned_games_cache @staticmethod async def parse_log_file(log_file, owned_title_ids, online_check_success): owned_title_ids_ = owned_title_ids checked_games_count = 0 total_games_count = len(games_cache) if os.path.exists(log_file): with FileReadBackwards(log_file, encoding="utf-8") as frb: while checked_games_count < total_games_count: try: line = frb.readline() except UnicodeDecodeError: log.warning( "ROCKSTAR_LOG_UNICODE_WARNING: An invalid Unicode character was found in the line " + line + ". Continuing to next line...") continue except Exception as e: log.error( "ROCKSTAR_LOG_ERROR: Reading " + line + " from the log file resulted in the " "exception " + repr(e) + " being thrown. Using the online list... (Please report " "this issue on the plugin's GitHub page!)") raise if not line: log.error( "ROCKSTAR_LOG_FINISHED_ERROR: The entire log file was read, but all of the games " "could not be accounted for. Proceeding to import the games that have been " "confirmed...") raise NoGamesInLogException() # We need to do two main things with the log file: # 1. If a game is present in owned_title_ids but not owned according to the log file, then it is # assumed to be a non-Launcher game, and is removed from the list. # 2. If a game is owned according to the log file but is not already present in owned_title_ids, # then it is assumed that the user has purchased the game on the Launcher, but has not yet played # it. In this case, the game will be added to owned_title_ids. if ("launcher" not in line) and ("on branch " in line): # Found a game! # Each log line for a title branch report describes the title id of the game starting at # character 65. Interestingly, the lines all have the same colon as character 75. This implies # that this format was intentionally done by Rockstar, so they likely will not change it anytime # soon. title_id = line[65:75].strip() log.debug( "ROCKSTAR_LOG_GAME: The game with title ID " + title_id + " is owned!") if title_id not in owned_title_ids_: if online_check_success is True: # Case 2: The game is owned, but has not been played. log.warning( "ROCKSTAR_UNPLAYED_GAME: The game with title ID " + title_id + " is owned, but it has never been played!") owned_title_ids_.append(title_id) checked_games_count += 1 elif "no branches!" in line: title_id = line[65:75].strip() if title_id in owned_title_ids_: # Case 1: The game is not actually owned on the launcher. log.warning( "ROCKSTAR_FAKE_GAME: The game with title ID " + title_id + " is not owned on " "the Rockstar Games Launcher!") owned_title_ids_.remove(title_id) checked_games_count += 1 if checked_games_count == total_games_count: break return owned_title_ids_ else: raise NoLogFoundException() async def get_local_games(self): # Since the API requires that get_local_games returns a list of LocalGame objects, local_list is the value that # needs to be returned. However, for internal use (the self.local_games_cache field), the dictionary local_games # is used for greater flexibility. local_games = {} local_list = [] for game in self.total_games_cache: title_id = get_game_title_id_from_ros_title_id(str(game.game_id)) check = self._local_client.get_path_to_game(title_id) if check is not None: if (title_id in self.running_games_pids and check_if_process_exists( self.running_games_pids[title_id][0])): local_game = self.create_local_game_from_title_id( title_id, True, True) else: local_game = self.create_local_game_from_title_id( title_id, False, True) else: local_game = self.create_local_game_from_title_id( title_id, False, False) local_games[title_id] = local_game local_list.append(local_game) self.local_games_cache = local_games log.debug("ROCKSTAR_INSTALLED_GAMES: " + str(local_games)) return local_list async def check_for_new_games(self): self.checking_for_new_games = True await self.get_owned_games() await asyncio.sleep(60) self.checking_for_new_games = False async def check_game_statuses(self): self.updating_game_statuses = True old_local_game_cache = self.local_games_cache await self.get_local_games() for title_id, local_game in self.local_games_cache.items(): if (local_game.local_game_state != (old_local_game_cache[title_id]).local_game_state): log.debug("ROCKSTAR_LOCAL_CHANGE: The status for " + title_id + " has changed.") self.update_local_game_status(local_game) # else: # log.debug("ROCKSTAR_LOCAL_REMAIN: The status for " + title_id + " has not changed.") - Reduce Console # Spam (Enable this if you need to.) await asyncio.sleep(5) self.updating_game_statuses = False async def launch_game(self, game_id): title_id = get_game_title_id_from_ros_title_id(game_id) self.running_games_pids[title_id] = [ await self._local_client.launch_game_from_title_id(title_id), True ] log.debug("ROCKSTAR_PIDS: " + str(self.running_games_pids)) if self.running_games_pids[title_id][0] != '-1': self.update_local_game_status( LocalGame(game_id, LocalGameState.Running | LocalGameState.Installed)) async def install_game(self, game_id): title_id = get_game_title_id_from_ros_title_id(game_id) log.debug("ROCKSTAR_INSTALL_REQUEST: Requesting to install " + title_id + "...") self._local_client.install_game_from_title_id(title_id) # If the game is not released yet, then we should allow them to see this on the Rockstar Games Launcher, but the # game's installation status should not be changed. if games_cache[title_id]["isPreOrder"]: return self.update_local_game_status( LocalGame(game_id, LocalGameState.Installed)) async def uninstall_game(self, game_id): title_id = get_game_title_id_from_ros_title_id(game_id) log.debug("ROCKSTAR_UNINSTALL_REQUEST: Requesting to uninstall " + title_id + "...") self._local_client.uninstall_game_from_title_id(title_id) self.update_local_game_status(LocalGame(game_id, LocalGameState.None_)) def create_game_from_title_id(self, title_id): return Game(self.games_cache[title_id]["rosTitleId"], self.games_cache[title_id]["friendlyName"], None, self.games_cache[title_id]["licenseInfo"]) def create_local_game_from_title_id(self, title_id, is_running, is_installed): if is_running: return LocalGame(self.games_cache[title_id]["rosTitleId"], LocalGameState.Running | LocalGameState.Installed) elif is_installed: return LocalGame(self.games_cache[title_id]["rosTitleId"], LocalGameState.Installed) else: return LocalGame(self.games_cache[title_id]["rosTitleId"], LocalGameState.None_) def tick(self): if not self.is_authenticated(): return if not self.checking_for_new_games: log.debug("Checking for new games...") asyncio.create_task(self.check_for_new_games()) if not self.updating_game_statuses: log.debug("Checking local game statuses...") asyncio.create_task(self.check_game_statuses())
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 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()