def setUpModule(): global base_uri global server_uri global client try: base_uri = os.environ['CALAMARI_BASE_URI'] except KeyError: log.error('Must define CALAMARI_BASE_URI') os._exit(1) if not base_uri.endswith('/'): base_uri += '/' if not base_uri.endswith('api/v1/'): base_uri += 'api/v1/' client = AuthenticatedHttpClient(base_uri, 'admin', 'admin') server_uri = base_uri.replace('api/v1/', '') client.login()
def __init__(self, reader, writer, token): super().__init__(Platform.Psn, __version__, reader, writer, token) self._http_client = AuthenticatedHttpClient(self.lost_authentication, self.store_credentials) self._psn_client = PSNClient(self._http_client) self._trophies_cache = Cache() logging.getLogger("urllib3").setLevel(logging.FATAL)
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): # Before the plugin shuts down, we need to store the final cookies. Specifically, ScAuthTokenData must remain # relevant for the plugin to continue working. log.debug("ROCKSTAR_SHUTDOWN: Storing final credentials...") self.store_credentials(self._http_client.get_credentials()) 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): # 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 = await 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(): for key, value in games_cache.items(): self.remove_game(value['rosTitleId']) self.owned_games_cache = [] return # 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. log_file = os.path.join(self.documents_location, "Rockstar Games\\Launcher\\launcher.log") log.debug("ROCKSTAR_LOG_LOCATION: Checking the file " + log_file + "...") 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: for line in frb: # 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 for title_id in owned_title_ids: game = self.create_game_from_title_id(title_id) if game not in self.owned_games_cache: self.owned_games_cache.append(game) for key, value in games_cache.items(): if key not in owned_title_ids: self.remove_game(value['rosTitleId']) return self.owned_games_cache else: log.warning("ROCKSTAR_LOG_WARNING: The log file could not be found and/or read from. Assuming that the " "online list is correct...") for title_id in owned_title_ids: game = self.create_game_from_title_id(title_id) if game not in self.owned_games_cache: self.owned_games_cache.append(game) for key, value in games_cache.items(): if key not in owned_title_ids: self.remove_game(value['rosTitleId']) return self.owned_games_cache async def get_local_games(self): local_games = [] 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) local_games.append(local_game) else: local_games.append(self.create_local_game_from_title_id(title_id, False, False)) self.local_games_cache = local_games log.debug("ROCKSTAR_INSTALLED_GAMES: " + str(local_games)) return local_games async def check_for_new_games(self): self.checking_for_new_games = True old_games_cache = self.owned_games_cache await self.get_owned_games() new_games_cache = self.owned_games_cache for game in new_games_cache: if game not in old_games_cache: self.add_game(game) await asyncio.sleep(60) self.checking_for_new_games = False async def check_game_statuses(self): self.updating_game_statuses = True for local_game in await self.get_local_games(): self.update_local_game_status(local_game) 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)) 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) 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) 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.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 ParadoxPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.ParadoxPlaza, __version__, reader, writer, token) self._http_client = AuthenticatedHttpClient(self.store_credentials) self.paradox_client = ParadoxClient(self._http_client) self.local_client = LocalClient() self.owned_games_cache = None self.local_games_cache = {} self.running_game = None self.tick_counter = 0 self.local_games_called = None self.owned_games_called = None self.update_installed_games_task = None self.update_running_games_task = None self.update_owned_games_task = None async def authenticate(self, stored_credentials=None): if stored_credentials: stored_cookies = pickle.loads( bytes.fromhex(stored_credentials['cookie_jar'])) self._http_client.authenticate_with_cookies(stored_cookies) self._http_client.set_auth_lost_callback(self.lost_authentication) acc_id = await self.paradox_client.get_account_id() return Authentication(str(acc_id), 'Paradox') if not stored_credentials: return NextStep("web_session", AUTH_PARAMS) async def pass_login_credentials(self, step, credentials, cookies): self._http_client.authenticate_with_cookies(cookies) self._http_client.set_auth_lost_callback(self.lost_authentication) acc_id = await self.paradox_client.get_account_id() return Authentication(str(acc_id), 'Paradox') async def get_owned_games(self): games_to_send = [] try: owned_games = await self.paradox_client.get_owned_games() sent_titles = set() for game in owned_games: log.info(game) if 'game' in game['type']: title = game['title'].replace(' (Paradox)', '') title = title.split(':')[0] if title in sent_titles: continue sent_titles.add(title) games_to_send.append( Game(title.lower().replace(' ', '_'), title, None, LicenseInfo(LicenseType.SinglePurchase))) self.owned_games_cache = games_to_send self.owned_games_called = True except Exception as e: log.error( f"Encountered exception while retriving owned games {repr(e)}") self.owned_games_called = True raise e return games_to_send if SYSTEM == System.WINDOWS: async def get_local_games(self): games_path = self.local_client.games_path if not games_path: self.local_games_called = True return [] local_games = os.listdir(games_path) games_to_send = [] local_games_cache = {} for local_game in local_games: game_folder = os.path.join(games_path, local_game) game_cpatch = os.path.join(game_folder, '.cpatch', local_game) try: with open(os.path.join(game_cpatch, 'version')) as game_cp: version = game_cp.readline() with open(os.path.join(game_cpatch, 'repository.json'), 'r') as js: game_repository = json.load(js) exe_path = game_repository['content']['versions'][version][ 'exePath'] except FileNotFoundError: continue except Exception as e: log.error( f"Unable to parse local game {local_game} {repr(e)}") continue local_games_cache[local_game] = os.path.join( game_folder, exe_path) games_to_send.append( LocalGame(local_game, LocalGameState.Installed)) self.local_games_cache = local_games_cache self.local_games_called = True return games_to_send if SYSTEM == System.WINDOWS: async def launch_game(self, game_id): exe_path = self.local_games_cache.get(game_id) log.info(f"Launching {exe_path}") game_dir = os.path.join(self.local_client.games_path, game_id) subprocess.Popen(exe_path, cwd=game_dir) if SYSTEM == System.WINDOWS: async def install_game(self, game_id): bootstraper_exe = self.local_client.bootstraper_exe if bootstraper_exe: subprocess.Popen(bootstraper_exe) return log.info("Local client not installed") webbrowser.open('https://play.paradoxplaza.com') if SYSTEM == System.WINDOWS: async def uninstall_game(self, game_id): bootstraper_exe = self.local_client.bootstraper_exe if bootstraper_exe: subprocess.call(bootstraper_exe) return log.info("Local client not installed") webbrowser.open('https://play.paradoxplaza.com') async def update_installed_games(self): games_path = self.local_client.games_path if not games_path: return [] local_games = os.listdir(games_path) local_games_cache = self.local_games_cache if len(local_games_cache) == len(local_games): return log.info("Number of local games changed, reparsing") await self.get_local_games() for game in local_games_cache: if not self.local_games_cache.get(game): self.update_local_game_status( LocalGame(game, LocalGameState.None_)) for game in self.local_games_cache: if not local_games_cache.get(game): self.update_local_game_status( LocalGame(game, LocalGameState.Installed)) async def update_running_games(self): await asyncio.sleep(1) local_games_cache = self.local_games_cache running_game = await self.local_client.get_running_game( local_games_cache) if not running_game and not self.running_game: pass elif not running_game: self.update_local_game_status( LocalGame(self.running_game.name, LocalGameState.Installed)) elif not self.running_game: self.update_local_game_status( LocalGame(running_game.name, LocalGameState.Installed | LocalGameState.Running)) elif self.running_game.name != running_game.name: self.update_local_game_status( LocalGame(self.running_game.name, LocalGameState.Installed)) self.update_local_game_status( LocalGame(running_game.name, LocalGameState.Installed | LocalGameState.Running)) self.running_game = running_game async def update_owned_games(self): owned_games_cache = self.owned_games_cache owned_games = await self.get_owned_games() log.info("Looking for new games") for game in owned_games: if game not in owned_games_cache: log.info(f"Adding game {game}") self.add_game(game) def tick(self): self.tick_counter += 1 if not self.owned_games_called or (sys.platform == 'win32' and not self.local_games_called): return if self.tick_counter % 60 == 0: if not self.update_owned_games_task or self.update_owned_games_task.done( ): self.update_owned_games_task = asyncio.create_task( self.update_owned_games()) if sys.platform != 'win32': return if not self.update_installed_games_task or self.update_installed_games_task.done( ): self.update_installed_games_task = asyncio.create_task( self.update_installed_games()) if not self.update_running_games_task or self.update_running_games_task.done( ): self.update_running_games_task = asyncio.create_task( self.update_running_games()) async def shutdown(self): await self._http_client.close() async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: return None async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: return OSCompatibility.Windows
class BethesdaPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.Bethesda, __version__, reader, writer, token) self._http_client = AuthenticatedHttpClient(self.store_credentials) self.bethesda_client = BethesdaClient(self._http_client) self.local_client = LocalClient() self.local_client.local_games_cache = self.persistent_cache.get( 'local_games') if not self.local_client.local_games_cache: self.local_client.local_games_cache = {} self.products_cache = product_cache self.owned_games_cache = None self._asked_for_local = False self.update_game_running_status_task = None self.update_game_installation_status_task = None self.betty_client_process_task = None self.check_for_new_games_task = None self.running_games = {} self.launching_lock = None self._tick = 1 async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep( "web_session", AUTH_PARAMS, cookies=[Cookie("passedICO", "true", ".bethesda.net")], js=JS) try: log.info("Got stored credentials") cookies = pickle.loads( bytes.fromhex(stored_credentials['cookie_jar'])) cookies_parsed = [] for cookie in cookies: if cookie.key in cookies_parsed and cookie.domain: self._http_client.update_cookies( {cookie.key: cookie.value}) elif cookie.key not in cookies_parsed: self._http_client.update_cookies( {cookie.key: cookie.value}) cookies_parsed.append(cookie.key) log.info("Finished parsing stored credentials, authenticating") user = await self._http_client.authenticate() self._http_client.set_auth_lost_callback(self.lost_authentication) return Authentication(user_id=user['user_id'], user_name=user['display_name']) except (AccessDenied, Banned, UnknownError) as e: log.error( f"Couldn't authenticate with stored credentials {repr(e)}") raise InvalidCredentials() async def pass_login_credentials(self, step, credentials, cookies): cookiez = {} illegal_keys = [''] for cookie in cookies: if cookie['name'] not in illegal_keys: cookiez[cookie['name']] = cookie['value'] self._http_client.update_cookies(cookiez) try: user = await self._http_client.authenticate() except Exception as e: log.error(repr(e)) raise InvalidCredentials() self._http_client.set_auth_lost_callback(self.lost_authentication) return Authentication(user_id=user['user_id'], user_name=user['display_name']) def _check_for_owned_products(self, owned_ids): products_to_consider = [ product for product in self.products_cache if 'reference_id' in self.products_cache[product] ] owned_product_ids = [] for entitlement_id in owned_ids: for product in products_to_consider: for reference_id in self.products_cache[product][ 'reference_id']: if entitlement_id in reference_id: self.products_cache[product]['owned'] = True owned_product_ids.append(entitlement_id) return owned_product_ids async def _get_owned_pre_orders(self, pre_order_ids): games_to_send = [] for pre_order in pre_order_ids: pre_order_details = await self.bethesda_client.get_game_details( pre_order) if pre_order_details and 'Entry' in pre_order_details: entries_to_consider = [ entry for entry in pre_order_details['Entry'] if 'fields' in entry and 'productName' in entry['fields'] ] for entry in entries_to_consider: if entry['fields']['productName'] in self.products_cache: self.products_cache[entry['fields'] ['productName']]['owned'] = True else: games_to_send.append( Game( pre_order, entry['fields']['productName'] + " (Pre Order)", None, LicenseInfo(LicenseType.SinglePurchase))) break return games_to_send def _get_owned_games(self): games_to_send = [] for product in self.products_cache: if self.products_cache[product]["owned"] and self.products_cache[ product]["free_to_play"]: games_to_send.append( Game(self.products_cache[product]['local_id'], product, None, LicenseInfo(LicenseType.FreeToPlay))) elif self.products_cache[product]["owned"]: games_to_send.append( Game(self.products_cache[product]['local_id'], product, None, LicenseInfo(LicenseType.SinglePurchase))) return games_to_send async def get_owned_games(self): owned_ids = [] games_to_send = [] try: owned_ids = await self.bethesda_client.get_owned_ids() except (UnknownError, BackendError) as e: log.warning(f"No owned games detected {repr(e)}") log.info(f"Owned Ids: {owned_ids}") product_ids = self._check_for_owned_products(owned_ids) pre_order_ids = set(owned_ids) - set(product_ids) games_to_send.extend(await self._get_owned_pre_orders(pre_order_ids)) games_to_send.extend(self._get_owned_games()) log.info(f"Games to send (with free games): {games_to_send}") self.owned_games_cache = games_to_send return games_to_send async def get_local_games(self): if sys.platform != 'win32': log.error(f"Incompatible platform {sys.platform}") return [] local_games = [] installed_products = self.local_client.get_installed_products( timeout=2, products_cache=self.products_cache) log.info(f"Installed products {installed_products}") for product in self.products_cache: for installed_product in installed_products: if installed_products[ installed_product] == self.products_cache[product][ 'local_id']: self.products_cache[product]['installed'] = True local_games.append( LocalGame(installed_products[installed_product], LocalGameState.Installed)) self._asked_for_local = True log.info(f"Returning local games {local_games}") return local_games async def install_game(self, game_id): if sys.platform != 'win32': log.error(f"Incompatible platform {sys.platform}") return if not self.local_client.is_installed(): await self._open_betty_browser() return if self.local_client.betty_client_process: self.local_client.focus_client_window() await self.launch_game(game_id) else: uuid = None for product in self.products_cache: if self.products_cache[product]['local_id'] == game_id: if self.products_cache[product]['installed']: log.warning( "Got install on already installed game, launching") return await self.launch_game(game_id) uuid = "\"" + self.products_cache[product]['uuid'] + "\"" cmd = "\"" + self.local_client.client_exe_path + "\"" + f" --installproduct={uuid}" log.info(f"Calling install game with command {cmd}") subprocess.Popen(cmd, shell=True) async def launch_game(self, game_id): if sys.platform != 'win32': log.error(f"Incompatible platform {sys.platform}") return if not self.local_client.is_installed(): await self._open_betty_browser() return for product in self.products_cache: if self.products_cache[product]['local_id'] == game_id: if not self.products_cache[product]['installed']: if not self.local_client.betty_client_process: log.warning( "Got launch on a not installed game, installing") return await self.install_game(game_id) else: if not self.local_client.betty_client_process: self.launching_lock = time.time() + 45 else: self.launching_lock = time.time() + 30 self.running_games[game_id] = None self.update_local_game_status( LocalGame( game_id, LocalGameState.Installed | LocalGameState.Running)) self.update_game_running_status_task.cancel() cmd = f"start bethesdanet://run/{game_id}" log.info(f"Calling launch command for id {game_id}, {cmd}") subprocess.Popen(cmd, shell=True) async def uninstall_game(self, game_id): if sys.platform != 'win32': log.error(f"Incompatible platform {sys.platform}") return if not self.local_client.is_installed(): await self._open_betty_browser() return for product in self.products_cache: if self.products_cache[product]['local_id'] == game_id: if not self.products_cache[product]['installed']: return log.info(f"Calling uninstall command for id {game_id}") cmd = f"start bethesdanet://uninstall/{game_id}" subprocess.Popen(cmd, shell=True) if not self.local_client.betty_client_process: await asyncio.sleep( 2) # QOL, bethesda slowly reacts to uninstall command, self.local_client.focus_client_window() async def _open_betty_browser(self): url = "https://bethesda.net/game/bethesda-launcher" log.info(f"Opening Bethesda website on url {url}") webbrowser.open(url) async def _heavy_installation_status_check(self): installed_products = self.local_client.get_installed_products( 4, self.products_cache) changed = False products_cache_installed_products = {} for product in self.products_cache: if self.products_cache[product]['installed']: products_cache_installed_products[ product] = self.products_cache[product]['local_id'] for installed_product in installed_products: if installed_product not in products_cache_installed_products: self.products_cache[installed_product]["installed"] = True self.update_local_game_status( LocalGame(installed_products[installed_product], LocalGameState.Installed)) changed = True for installed_product in products_cache_installed_products: if installed_product not in installed_products: self.products_cache[installed_product]["installed"] = False self.update_local_game_status( LocalGame( products_cache_installed_products[installed_product], LocalGameState.None_)) changed = True return changed def _light_installation_status_check(self): changed = False for local_game in self.local_client.local_games_cache: local_game_installed = self.local_client.is_local_game_installed( self.local_client.local_games_cache[local_game]) if local_game_installed and not self.products_cache[local_game][ "installed"]: self.products_cache[local_game]["installed"] = True self.update_local_game_status( LocalGame( self.local_client.local_games_cache[local_game] ['local_id'], LocalGameState.Installed)) changed = True elif not local_game_installed and self.products_cache[local_game][ "installed"]: self.products_cache[local_game]["installed"] = False self.update_local_game_status( LocalGame( self.local_client.local_games_cache[local_game] ['local_id'], LocalGameState.None_)) changed = True return changed async def update_game_installation_status(self): if self.local_client.clientgame_changed( ) or self.local_client.launcher_children_number_changed(): await asyncio.sleep(1) log.info("Starting heavy installation status check") if await self._heavy_installation_status_check(): # Game status has changed self.persistent_cache[ 'local_games'] = self.local_client.local_games_cache self.push_cache() else: if self._light_installation_status_check(): # Game status has changed self.persistent_cache[ 'local_games'] = self.local_client.local_games_cache self.push_cache() async def _scan_running_games(self, process_iter_interval): for process in process_iter(): await asyncio.sleep(process_iter_interval) for local_game in self.local_client.local_games_cache: if not process.binary_path: continue if process.binary_path in self.local_client.local_games_cache[ local_game]['execs']: log.info(f"Found a running game! {local_game}") local_id = self.local_client.local_games_cache[local_game][ 'local_id'] if local_id not in self.running_games: self.update_local_game_status( LocalGame( local_id, LocalGameState.Installed | LocalGameState.Running)) self.running_games[local_id] = RunningGame( self.local_client.local_games_cache[local_game] ['execs'], process) async def _update_status_of_already_running_games(self, process_iter_interval, dont_downgrade_status): for running_game in self.running_games.copy(): if not self.running_games[running_game] and dont_downgrade_status: log.info(f"Found 'just launched' game {running_game}") continue elif not self.running_games[running_game]: log.info( f"Found 'just launched' game but its still without pid and its time run out {running_game}" ) self.running_games.pop(running_game) self.update_local_game_status( LocalGame(running_game, LocalGameState.Installed)) continue for process in process_iter(): await asyncio.sleep(process_iter_interval) if process.binary_path in self.running_games[ running_game].execs: return True self.running_games.pop(running_game) self.update_local_game_status( LocalGame(running_game, LocalGameState.Installed)) async def update_game_running_status(self): process_iter_interval = 0.02 dont_downgrade_status = False if self.launching_lock and self.launching_lock >= time.time(): process_iter_interval = 0.01 dont_downgrade_status = True if self.running_games: # Don't iterate over processes if a game is already running, assuming user is playing one game at a time. if not await self._update_status_of_already_running_games( process_iter_interval, dont_downgrade_status): await self._scan_running_games(process_iter_interval) await asyncio.sleep(1) return await self._scan_running_games(process_iter_interval) await asyncio.sleep(1) async def check_for_new_games(self): games_cache = self.owned_games_cache owned_games = await self.get_owned_games() for owned_game in owned_games: if owned_game not in games_cache: self.add_game(owned_game) await asyncio.sleep(60) async def close_bethesda_window(self): if sys.platform != 'win32': return window_name = "Bethesda.net Launcher" max_delay = 10 intermediate_sleep = 0.05 stop_time = time.time() + max_delay def timed_out(): if time.time() >= stop_time: log.warning(f"Timed out trying to close {window_name}") return True return False try: hwnd = ctypes.windll.user32.FindWindowW(None, window_name) while not ctypes.windll.user32.IsWindowVisible(hwnd): hwnd = ctypes.windll.user32.FindWindowW(None, window_name) await asyncio.sleep(intermediate_sleep) if timed_out(): return while ctypes.windll.user32.IsWindowVisible(hwnd): await asyncio.sleep(intermediate_sleep) ctypes.windll.user32.CloseWindow(hwnd) if timed_out(): return except Exception as e: log.error( f"Exception when checking if window is visible {repr(e)}") async def shutdown_platform_client(self): if sys.platform != 'win32': return log.info("killing bethesda") subprocess.Popen("taskkill.exe /im \"BethesdaNetLauncher.exe\"") async def launch_platform_client(self): if not self.local_client.betty_client_process: return if sys.platform != 'win32': return log.info("launching bethesda") subprocess.Popen('start bethesdanet://', shell=True) asyncio.create_task(self.close_bethesda_window()) def tick(self): if sys.platform == 'win32': if self._asked_for_local and ( not self.update_game_installation_status_task or self.update_game_installation_status_task.done()): self.update_game_installation_status_task = asyncio.create_task( self.update_game_installation_status()) if self._asked_for_local and ( not self.update_game_running_status_task or self.update_game_running_status_task.done()): self.update_game_running_status_task = asyncio.create_task( self.update_game_running_status()) if not self.betty_client_process_task or self.betty_client_process_task.done( ): self.betty_client_process_task = asyncio.create_task( self.local_client.is_running()) if self.owned_games_cache and (not self.check_for_new_games_task or self.check_for_new_games_task.done()): self.check_for_new_games_task = asyncio.create_task( self.check_for_new_games()) async def shutdown(self): await self._http_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.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, "Suspended": LicenseType.SinglePurchase } 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()
class EpicPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.Epic, __version__, reader, writer, token) self._http_client = AuthenticatedHttpClient(store_credentials_callback=self.store_credentials) self._epic_client = EpicClient(self._http_client) self._local_provider = LocalGamesProvider() self._local_client = local_client self._owned_games = {} self._game_info_cache = {} self._encoder = JSONEncoder() self._refresh_owned_task = None async def _do_auth(self): user_info = await self._epic_client.get_users_info([self._http_client.account_id]) display_name = self._epic_client.get_display_name(user_info) self._http_client.set_auth_lost_callback(self.lost_authentication) return Authentication(self._http_client.account_id, display_name) async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", AUTH_PARAMS) refresh_token = stored_credentials["refresh_token"] try: await self._http_client.authenticate_with_refresh_token(refresh_token) except (BackendNotAvailable, BackendError, BackendTimeout, NetworkError, UnknownError) as e: raise e except Exception: raise InvalidCredentials() return await self._do_auth() async def pass_login_credentials(self, step, credentials, cookies): try: if cookies: cookiez = {} for cookie in cookies: cookiez[cookie['name']] = cookie['value'] self._http_client.update_cookies(cookiez) exchange_code = await self._http_client.retrieve_exchange_code() await self._http_client.authenticate_with_exchange_code(exchange_code) except (BackendNotAvailable, BackendError, BackendTimeout, NetworkError, UnknownError) as e: raise e except Exception as e: log.error(repr(e)) raise InvalidCredentials() return await self._do_auth() def handshake_complete(self): self._game_info_cache = { k: GameInfo(**v) for k, v in json.loads(self.persistent_cache.get('game_info', '{}')).items() } def _store_cache(self, key, obj): self.persistent_cache[key] = self._encoder.encode(obj) self.push_cache() def store_credentials(self, credentials: dict): """Prevents losing credentials on `push_cache`""" self.persistent_cache['credentials'] = self._encoder.encode(credentials) super().store_credentials(credentials) def _get_dlcs(self, products): dlcs = [] for game in products['data']['Launcher']['libraryItems']['records']: try: if 'mainGameItem' in game['catalogItem'] and game['catalogItem']['mainGameItem']: dlcs.append(EpicDlc(game['catalogItem']['mainGameItem']['id'], game['catalogItemId'], game['catalogItem']['title'])) except (TypeError, KeyError) as e: log.error(f"Exception while trying to parse product {repr(e)}\nProduct {game}") return dlcs def _parse_owned_product(self, game, dlcs): games_dlcs = [] is_game = False is_application = False for category in game['catalogItem']['categories']: if category['path'] == 'games': is_game = True if category['path'] == 'applications': is_application = True if not is_game or not is_application: return for dlc in dlcs: if game['catalogItemId'] == dlc.parent_id: games_dlcs.append(Dlc(dlc.dlc_id, dlc.dlc_title, LicenseInfo(LicenseType.SinglePurchase))) if game['catalogItemId'] == dlc.dlc_id: # product is a dlc, skip return self._game_info_cache[game['appName']] = GameInfo(game['namespace'], game['appName'], game['catalogItem']['title']) return Game(game['appName'], game['catalogItem']['title'], games_dlcs, LicenseInfo(LicenseType.SinglePurchase)) async def _get_owned_games(self): parsed_games = [] owned_products = await self._epic_client.get_owned_games() dlcs = self._get_dlcs(owned_products) product_mapping = await self._epic_client.get_productmapping() for product in owned_products['data']['Launcher']['libraryItems']['records']: try: parsed_game = self._parse_owned_product(product, dlcs) if parsed_game: cached_game_info = self._game_info_cache.get(parsed_game.game_id) if cached_game_info.namespace in product_mapping: parsed_games.append(parsed_game) except (TypeError, KeyError) as e: log.error(f"Exception while trying to parse product {repr(e)}\nProduct {product}") self._store_cache('game_info', self._game_info_cache) return parsed_games async def get_owned_games(self): games = await self._get_owned_games() for game in games: self._owned_games[game.game_id] = game self._refresh_owned_task = asyncio.create_task(self._check_for_new_games(300)) return games async def get_local_games(self): if self._local_provider.first_run: self._local_provider.setup() return [ LocalGame(app_name, state) for app_name, state in self._local_provider.games.items() ] async def _get_store_slug(self, game_id): cached_game_info = self._game_info_cache.get(game_id) try: if cached_game_info: title = cached_game_info.title namespace = cached_game_info.namespace else: # extra safety fallback in case of dealing with removed game assets = await self._epic_client.get_assets() for asset in assets: if asset.app_name == game_id: if game_id in self._owned_games: title = self._owned_games[game_id].game_title else: details = await self._epic_client.get_catalog_items_with_id(asset.namespace, asset.catalog_id) title = details.title namespace = asset.namespace product_store_info = await self._epic_client.get_product_store_info(title) if "data" in product_store_info: for product in product_store_info["data"]["Catalog"]["catalogOffers"]["elements"]: if product["linkedOfferNs"] == namespace: return product['productSlug'] return "" except Exception as e: log.error(repr(e)) return "" async def open_epic_browser(self, store_slug=None): if store_slug: url = f"https://www.epicgames.com/store/install/{store_slug}" else: url = "https://www.epicgames.com/store/download" log.info(f"Opening Epic website {url}") webbrowser.open(url) def _is_game_installed(self, game_id): try: game_state = self._local_provider.games[game_id] if game_state is not LocalGameState.Installed: return False return True except KeyError: return False async def launch_game(self, game_id): if self._local_provider.is_game_running(game_id): log.info(f'Game already running, game_id: {game_id}.') return if SYSTEM == System.WINDOWS: cmd = f"com.epicgames.launcher://apps/{game_id}?action=launch^&silent=true" elif SYSTEM == System.MACOS: cmd = f"'com.epicgames.launcher://apps/{game_id}?action=launch&silent=true'" try: await self._local_client.exec(cmd) except ClientNotInstalled: await self.open_epic_browser() else: await self._local_provider.search_process(game_id, timeout=30) async def uninstall_game(self, game_id): if not self._is_game_installed(game_id): log.warning("Received uninstall command on a not installed game") return cmd = "com.epicgames.launcher://store/library" try: await self._local_client.exec(cmd) except ClientNotInstalled: await self.open_epic_browser(await self._get_store_slug(game_id)) async def install_game(self, game_id): if self._is_game_installed(game_id): log.warning(f"Game {game_id} is already installed") return await self.launch_game(game_id) cmd = "com.epicgames.launcher://store/library" try: await self._local_client.exec(cmd) except ClientNotInstalled: await self.open_epic_browser(await self._get_store_slug(game_id)) async def get_friends(self): ids = await self._epic_client.get_friends_list() account_ids = [] friends = [] prev_slice = 0 for index, entry in enumerate(ids): account_ids.append(entry["accountId"]) ''' Send request for friends information in batches of 50 so the request isn't too large, 50 is an arbitrary number, to be tailored if need be ''' if index + 1 % 50 == 0 or index == len(ids) - 1: friends.extend(await self._epic_client.get_users_info(account_ids[prev_slice:])) prev_slice = index friend_infos = [] for friend in friends: if "id" in friend and "displayName" in friend: friend_infos.append(FriendInfo(user_id=friend["id"], user_name=friend["displayName"])) elif "id" in friend: friend_infos.append(FriendInfo(user_id=friend["id"], user_name="")) return friend_infos def _update_local_game_statuses(self): updated = self._local_provider.consume_updated_games() for id_ in updated: new_state = self._local_provider.games[id_] log.debug(f'Updating game {id_} state to {new_state}') self.update_local_game_status(LocalGame(id_, new_state)) async def _check_for_new_games(self, interval): await asyncio.sleep(interval) log.info("Checking for new games") refreshed_owned_games = await self._get_owned_games() for game in refreshed_owned_games: if game.game_id not in self._owned_games: log.info(f"Found new game, {game}") self.add_game(game) self._owned_games[game.game_id] = game async def prepare_game_times_context(self, game_ids): return await self._epic_client.get_playtime() async def get_game_time(self, game_id, context): if context: playtime = context else: playtime = await self.prepare_game_times_context(None) time_played = None for item in playtime['data']['PlaytimeTracking']['total']: if item['artifactId'] == game_id and 'totalTime' in item: time_played = int(item['totalTime']/60) break return GameTime(game_id, time_played, None) async def prepare_local_size_context(self, game_ids) -> dict: return parse_manifests() async def get_local_size(self, game_id, context) -> int: try: game_manifest = context[game_id] return int(game_manifest['InstallSize']) except (KeyError, ValueError) as e: raise FailedParsingManifest(repr(e)) async def launch_platform_client(self): if self._local_provider.is_client_running: log.info("Epic client already running") return cmd = "com.epicgames.launcher:" await self._local_client.exec(cmd) asyncio.create_task(self._local_client.prevent_epic_from_showing()) async def shutdown_platform_client(self): await self._local_client.shutdown_platform_client() def tick(self): if not self._local_provider.first_run: self._update_local_game_statuses() if self._refresh_owned_task and self._refresh_owned_task.done(): # Interval set to 8 minutes because that makes the request number just below galaxy's own calls # and still maintains the functionality self._refresh_owned_task = asyncio.create_task(self._check_for_new_games(60*8)) async def shutdown(self): if self._local_provider._status_updater: self._local_provider._status_updater.cancel() if self._http_client: await self._http_client.close()
async def http_client(): store_credentials = MagicMock() client = AuthenticatedHttpClient(store_credentials) yield client await client.close()
class PSNPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.Psn, __version__, reader, writer, token) self._http_client = AuthenticatedHttpClient(self.lost_authentication) self._psn_client = PSNClient(self._http_client) self._comm_ids_cache: Dict[TitleId, CommunicationId] = {} self._trophies_cache = Cache() logging.getLogger("urllib3").setLevel(logging.FATAL) async def _do_auth(self, npsso): if not npsso: raise InvalidCredentials() try: await self._http_client.authenticate(npsso) user_id, user_name = await self._psn_client.async_get_own_user_info() except Exception: raise InvalidCredentials() return Authentication(user_id=user_id, user_name=user_name) async def authenticate(self, stored_credentials=None): stored_npsso = stored_credentials.get("npsso") if stored_credentials else None if not stored_npsso: return NextStep("web_session", AUTH_PARAMS) return await self._do_auth(stored_npsso) async def pass_login_credentials(self, step, credentials, cookies): def get_npsso(): for c in cookies: if c["name"] == "npsso" and c["value"]: return c["value"] npsso = get_npsso() auth_info = await self._do_auth(npsso) self.store_credentials({"npsso": npsso}) return auth_info @staticmethod def _is_game(comm_id: CommunicationId) -> bool: return comm_id != COMM_ID_NOT_AVAILABLE async def update_communication_id_cache(self, title_ids: List[TitleId]) -> Dict[TitleId, CommunicationId]: async def updater(title_id_slice: Iterable[TitleId]): delta.update(await self._psn_client.async_get_game_communication_id_map(title_id_slice)) delta: Dict[TitleId, CommunicationId] = dict() await asyncio.gather(*[ updater(title_ids[it:it + MAX_TITLE_IDS_PER_REQUEST]) for it in range(0, len(title_ids), MAX_TITLE_IDS_PER_REQUEST) ]) self._comm_ids_cache.update(delta) return delta async def get_game_communication_ids(self, title_ids: List[TitleId]) -> Dict[TitleId, CommunicationId]: result: Dict[TitleId, CommunicationId] = dict() misses: Set[TitleId] = set() for title_id in title_ids: comm_id: CommunicationId = self._comm_ids_cache.get(title_id) if comm_id: result[title_id] = comm_id else: misses.add(title_id) if misses: result.update(await self.update_communication_id_cache(list(misses))) return result async def get_owned_games(self): async def filter_games(titles): comm_id_map = await self.get_game_communication_ids([t.game_id for t in titles]) return [title for title in titles if self._is_game(comm_id_map[title.game_id])] return await filter_games( await self._psn_client.async_get_owned_games() ) # TODO: backward compatibility. remove when GLX handles batch imports async def get_unlocked_achievements(self, game_id: TitleId): async def get_game_comm_id(): comm_id: CommunicationId = (await self.get_game_communication_ids([game_id]))[game_id] if not self._is_game(comm_id): raise InvalidParams() return comm_id return await self._psn_client.async_get_earned_trophies( await get_game_comm_id() ) async def start_achievements_import(self, game_ids: List[TitleId]): if not self._http_client.is_authenticated: raise AuthenticationRequired await super().start_achievements_import(game_ids) async def import_games_achievements(self, game_ids: Iterable[TitleId]): try: comm_ids = await self.get_game_communication_ids(game_ids) trophy_titles = await self._psn_client.get_trophy_titles() except ApplicationError as error: for game_id in game_ids: self.game_achievements_import_failure(game_id, error) # make a map trophy_titles = {trophy_title.communication_id: trophy_title for trophy_title in trophy_titles} requests = [] for game_id, comm_id in comm_ids.items(): if not self._is_game(comm_id): self.game_achievements_import_failure(game_id, InvalidParams()) continue trophy_title = trophy_titles.get(comm_id) if trophy_title is None: self.game_achievements_import_success(game_id, []) continue trophies = self._trophies_cache.get(comm_id, trophy_title.last_update_time) if trophies is not None: self.game_achievements_import_success(game_id, trophies) continue requests.append(self._import_game_achievements(game_id, comm_id)) await asyncio.gather(*requests) async def _import_game_achievements(self, title_id: TitleId, comm_id: CommunicationId): try: trophies: List[Achievement] = await self._psn_client.async_get_earned_trophies(comm_id) timestamp = max(trophy.unlock_time for trophy in trophies) self._trophies_cache.update(comm_id, trophies, timestamp) self.game_achievements_import_success(title_id, trophies) except ApplicationError as error: self.game_achievements_import_failure(title_id, error) except Exception: logging.exception("Unhandled exception. Please report it to the plugin developers") self.game_achievements_import_failure(title_id, UnknownError()) async def get_friends(self): return await self._psn_client.async_get_friends() def shutdown(self): asyncio.create_task(self._http_client.logout())
class EpicPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.Epic, __version__, reader, writer, token) self._http_client = AuthenticatedHttpClient(store_credentials_callback=self.store_credentials) self._epic_client = EpicClient(self._http_client) self._local_provider = LocalGamesProvider() self._games_cache = {} self._refresh_owned_task = None async def _do_auth(self): user_info = await self._epic_client.get_users_info([self._http_client.account_id]) display_name = self._epic_client.get_display_name(user_info) self._http_client.set_auth_lost_callback(self.lost_authentication) return Authentication(self._http_client.account_id, display_name) async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", AUTH_PARAMS, js=AUTH_JS) refresh_token = stored_credentials["refresh_token"] try: await self._http_client.authenticate_with_refresh_token(refresh_token) except Exception: # TODO: distinguish between login-related and all other (networking, server, e.t.c.) errors raise InvalidCredentials() return await self._do_auth() async def pass_login_credentials(self, step, credentials, cookies): try: await self._http_client.authenticate_with_exchage_code( credentials["end_uri"].split(AUTH_REDIRECT_URL, 1)[1] ) except Exception: # TODO: distinguish between login-related and all other (networking, server, e.t.c.) errors raise InvalidCredentials() return await self._do_auth() async def _get_title_sanitized(self, app_name): if app_name in self._games_cache: return self._games_cache[app_name].game_title.replace(" ", "-").lower() log.debug('Nothing found, fallback to epic client') assets = await self._epic_client.get_assets() for asset in assets: if asset.app_name == app_name: details = await self._epic_client.get_catalog_items(asset.namespace, asset.catalog_id) return details.title.replace(" ", "-").lower() log.warning(f'Game {app_name} was not found in assets') raise UnknownBackendResponse() async def _get_owned_games(self): requests = [] assets = await self._epic_client.get_assets() for namespace, _, catalog_id in assets: requests.append(self._epic_client.get_catalog_items(namespace, catalog_id)) items = await asyncio.gather(*requests) games = [] for i, item in enumerate(items): if "games" not in item.categories: continue game = Game(assets[i].app_name, item.title, None, LicenseInfo(LicenseType.SinglePurchase)) games.append(game) return games async def get_owned_games(self): games = await self._get_owned_games() for game in games: self._games_cache[game.game_id] = game self._refresh_owned_task = asyncio.create_task(self._check_for_new_games()) return games async def get_local_games(self): if self._local_provider.first_run: self._local_provider.setup() return [ LocalGame(app_name, state) for app_name, state in self._local_provider.games.items() ] async def open_epic_browser(self, game_id): try: title = await self._get_title_sanitized(game_id) title = title.replace(" ", "-").lower() except UnknownBackendResponse: url = "https://www.epicgames.com/" else: url = f"https://www.epicgames.com/store/product/{title}/home" log.info(f"Opening Epic website {url}") webbrowser.open(url) @property def _open(self): if SYSTEM == System.WINDOWS: return "start" elif SYSTEM == System.MACOS: return "open" async def launch_game(self, game_id): if not self._local_provider.is_launcher_installed: await self.open_epic_browser(game_id) return if self._local_provider.is_game_running(game_id): log.info('Game already running.') return cmd = f"{self._open} com.epicgames.launcher://apps/{game_id}?action=launch^&silent=true" log.info(f"Launching game {game_id}") subprocess.Popen(cmd, shell=True) await self._local_provider.search_process(game_id, timeout=30) async def uninstall_game(self, game_id): if not self._local_provider.is_launcher_installed: await self.open_epic_browser(game_id) return title = await self._get_title_sanitized(game_id) cmd = f"{self._open} com.epicgames.launcher://store/product/{title}/home" log.info(f"Uninstalling game {title}") subprocess.Popen(cmd, shell=True) async def install_game(self, game_id): if not self._local_provider.is_launcher_installed: await self.open_epic_browser(game_id) return title = await self._get_title_sanitized(game_id) cmd = f"{self._open} com.epicgames.launcher://store/product/{title}/home" log.info(f"Installing game {title}") subprocess.Popen(cmd, shell=True) async def get_friends(self): ids = await self._epic_client.get_friends_list() account_ids = [] friends = [] prev_slice = 0 log.debug(ids) for index, entry in enumerate(ids): account_ids.append(entry["accountId"]) ''' Send request for friends information in batches of 50 so the request isn't too large, 50 is an arbitrary number, to be tailored if need be ''' if index + 1 % 50 == 0 or index == len(ids) - 1: friends.extend(await self._epic_client.get_users_info(account_ids[prev_slice:])) prev_slice = index return[ FriendInfo(user_id=friend["id"], user_name=friend["displayName"]) for friend in friends ] def _update_local_game_statuses(self): updated = self._local_provider.consume_updated_games() for id_ in updated: new_state = self._local_provider.games[id_] log.debug(f'Updating game {id_} state to {new_state}') self.update_local_game_status(LocalGame(id_, new_state)) async def _check_for_new_games(self): await asyncio.sleep(60) # interval log.info("Checking for new games") assets = await self._epic_client.get_assets() for namespace, app_name, catalog_id in assets: if app_name not in self._games_cache and namespace != "ue": details = await self._epic_client.get_catalog_items(namespace, catalog_id) if "games" not in details.categories: continue game = Game(app_name, details.title, None, LicenseInfo(LicenseType.SinglePurchase)) log.info(f"Found new game, {game}") self.add_game(game) self._games_cache[game.game_id] = game def tick(self): if not self._local_provider.first_run: self._update_local_game_statuses() if self._refresh_owned_task and self._refresh_owned_task.done(): self._refresh_owned_task = asyncio.create_task(self._check_for_new_games()) def shutdown(self): self._local_provider._status_updater.cancel() asyncio.create_task(self._http_client.close())
class BethesdaPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.Bethesda, __version__, reader, writer, token) self._http_client = AuthenticatedHttpClient(self.store_credentials) self.bethesda_client = BethesdaClient(self._http_client) self.local_client = LocalClient() self.products_cache = product_cache self.owned_games_cache = None self._asked_for_local = False self.update_game_running_status_task = None self.update_game_installation_status_task = None self.check_for_new_games_task = None self.running_games = {} self.launching_lock = None async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep( "web_session", AUTH_PARAMS, cookies=[Cookie("passedICO", "true", ".bethesda.net")]) try: log.info("Got stored credentials") cookies = pickle.loads( bytes.fromhex(stored_credentials['cookie_jar'])) cookies_parsed = [] for cookie in cookies: if cookie.key in cookies_parsed and cookie.domain: self._http_client.update_cookies( {cookie.key: cookie.value}) elif cookie.key not in cookies_parsed: self._http_client.update_cookies( {cookie.key: cookie.value}) cookies_parsed.append(cookie.key) log.info("Finished parsing stored credentials, authenticating") user = await self._http_client.authenticate() return Authentication(user_id=user['user_id'], user_name=user['display_name']) except Exception as e: log.error( f"Couldn't authenticate with stored credentials {repr(e)}") raise InvalidCredentials() async def pass_login_credentials(self, step, credentials, cookies): cookiez = {} for cookie in cookies: cookiez[cookie['name']] = cookie['value'] self._http_client.update_cookies(cookiez) try: user = await self._http_client.authenticate() except Exception as e: log.error(repr(e)) raise InvalidCredentials() return Authentication(user_id=user['user_id'], user_name=user['display_name']) async def get_owned_games(self): owned_ids = [] matched_ids = [] games_to_send = [] pre_orders = [] try: owned_ids = await self.bethesda_client.get_owned_ids() except UnknownError as e: log.warning(f"No owned games detected {repr(e)}") log.info(f"Owned Ids: {owned_ids}") if owned_ids: for entitlement_id in owned_ids: for product in self.products_cache: if 'reference_id' in self.products_cache[product]: for reference_id in self.products_cache[product][ 'reference_id']: if entitlement_id in reference_id: self.products_cache[product]['owned'] = True matched_ids.append(entitlement_id) pre_orders = set(owned_ids) - set(matched_ids) for pre_order in pre_orders: pre_order_details = await self.bethesda_client.get_game_details( pre_order) if pre_order_details and 'Entry' in pre_order_details: for entry in pre_order_details['Entry']: if 'fields' in entry and 'productName' in entry['fields']: if entry['fields'][ 'productName'] in self.products_cache: self.products_cache[ entry['fields']['productName']]['owned'] = True else: games_to_send.append( Game( pre_order, entry['fields']['productName'] + " (Pre Order)", None, LicenseInfo(LicenseType.SinglePurchase))) break for product in self.products_cache: if self.products_cache[product]["owned"] and self.products_cache[ product]["free_to_play"]: games_to_send.append( Game(self.products_cache[product]['local_id'], product, None, LicenseInfo(LicenseType.FreeToPlay))) elif self.products_cache[product]["owned"]: games_to_send.append( Game(self.products_cache[product]['local_id'], product, None, LicenseInfo(LicenseType.SinglePurchase))) log.info(f"Games to send (with free games): {games_to_send}") self.owned_games_cache = games_to_send return games_to_send async def get_local_games(self): local_games = [] installed_products = self.local_client.get_installed_games( self.products_cache) log.info(f"Installed products {installed_products}") for product in self.products_cache: for installed_product in installed_products: if installed_products[ installed_product] == self.products_cache[product][ 'local_id']: self.products_cache[product]['installed'] = True local_games.append( LocalGame(installed_products[installed_product], LocalGameState.Installed)) self._asked_for_local = True log.info(f"Returning local games {local_games}") return local_games async def install_game(self, game_id): if sys.platform != 'win32': log.error(f"Incompatible platform {sys.platform}") return if not self.local_client.is_installed: await self._open_betty_browser() return if self.local_client.is_running: self.local_client.focus_client_window() await self.launch_game(game_id) else: uuid = None for product in self.products_cache: if self.products_cache[product]['local_id'] == game_id: if self.products_cache[product]['installed']: log.warning( "Got install on already installed game, launching") return await self.launch_game(game_id) uuid = "\"" + self.products_cache[product]['uuid'] + "\"" cmd = "\"" + self.local_client.client_exe_path + "\"" + f" --installproduct={uuid}" log.info(f"Calling install game with command {cmd}") subprocess.Popen(cmd, shell=True) async def launch_game(self, game_id): if sys.platform != 'win32': log.error(f"Incompatible platform {sys.platform}") return if not self.local_client.is_installed: await self._open_betty_browser() return for product in self.products_cache: if self.products_cache[product]['local_id'] == game_id: if not self.products_cache[product]['installed']: if not self.local_client.is_running: log.warning( "Got launch on a not installed game, installing") return await self.install_game(game_id) else: if not self.local_client.is_running: self.launching_lock = time.time() + 45 else: self.launching_lock = time.time() + 30 self.running_games[game_id] = None self.update_local_game_status( LocalGame( game_id, LocalGameState.Installed | LocalGameState.Running)) self.update_game_running_status_task.cancel() log.info(f"Calling launch command for id {game_id}") cmd = f"start bethesdanet://run/{game_id}" subprocess.Popen(cmd, shell=True) async def uninstall_game(self, game_id): if sys.platform != 'win32': log.error(f"Incompatible platform {sys.platform}") return if not self.local_client.is_installed: await self._open_betty_browser() return for product in self.products_cache: if self.products_cache[product]['local_id'] == game_id: if not self.products_cache[product]['installed']: return log.info(f"Calling uninstall command for id {game_id}") cmd = f"start bethesdanet://uninstall/{game_id}" subprocess.Popen(cmd, shell=True) if self.local_client.is_running: await asyncio.sleep( 2) # QOL, bethesda slowly reacts to uninstall command, self.local_client.focus_client_window() async def _open_betty_browser(self): url = "https://bethesda.net/game/bethesda-launcher" log.info(f"Opening Bethesda website on url {url}") webbrowser.open(url) async def _heavy_installation_status_check(self): installed_products = self.local_client.get_installed_games( self.products_cache) products_cache_installed_products = {} for product in self.products_cache: if self.products_cache[product]['installed']: products_cache_installed_products[ product] = self.products_cache[product]['local_id'] for installed_product in installed_products: if installed_product not in products_cache_installed_products: self.products_cache[installed_product]["installed"] = True self.update_local_game_status( LocalGame(installed_products[installed_product], LocalGameState.Installed)) for installed_product in products_cache_installed_products: if installed_product not in installed_products: self.products_cache[installed_product]["installed"] = False self.update_local_game_status( LocalGame( products_cache_installed_products[installed_product], LocalGameState.None_)) def _light_installation_status_check(self): for local_game in self.local_client.local_games_cache: local_game_installed = self.local_client.is_local_game_installed( self.local_client.local_games_cache[local_game]) if local_game_installed and not self.products_cache[local_game][ "installed"]: self.products_cache[local_game]["installed"] = True self.update_local_game_status( LocalGame( self.local_client.local_games_cache[local_game] ['local_id'], LocalGameState.Installed)) elif not local_game_installed and self.products_cache[local_game][ "installed"]: self.products_cache[local_game]["installed"] = False self.update_local_game_status( LocalGame( self.local_client.local_games_cache[local_game] ['local_id'], LocalGameState.None_)) async def update_game_installation_status(self): if self.local_client.clientgame_changed(): await asyncio.sleep(1) await self._heavy_installation_status_check() else: self._light_installation_status_check() async def update_game_running_status(self): process_iter_interval = 0.10 dont_downgrade_status = False if self.launching_lock and self.launching_lock >= time.time(): dont_downgrade_status = True process_iter_interval = 0.01 for running_game in self.running_games.copy(): if not self.running_games[running_game] and dont_downgrade_status: log.info(f"Found 'just launched' game {running_game}") continue elif not self.running_games[running_game]: log.info( f"Found 'just launched' game but its still without pid and its time run out {running_game}" ) self.running_games.pop(running_game) self.update_local_game_status( LocalGame(running_game, LocalGameState.Installed)) continue if self.running_games[running_game].is_running(): return self.running_games.pop(running_game) self.update_local_game_status( LocalGame(running_game, LocalGameState.Installed)) for process in psutil.process_iter(attrs=['name'], ad_value=''): await asyncio.sleep(process_iter_interval) for local_game in self.local_client.local_games_cache: try: if process.name().lower( ) in self.local_client.local_games_cache[local_game][ 'execs']: log.info(f"Found a running game! {local_game}") local_id = self.local_client.local_games_cache[ local_game]['local_id'] if local_id not in self.running_games: self.update_local_game_status( LocalGame( local_id, LocalGameState.Installed | LocalGameState.Running)) self.running_games[local_id] = process return except (psutil.AccessDenied, psutil.NoSuchProcess): break await asyncio.sleep(3) async def check_for_new_games(self): owned_games = await self.get_owned_games() for owned_game in owned_games: if owned_game not in self.owned_games_cache: self.add_game(owned_game) self.owned_games_cache = owned_games await asyncio.sleep(60) def tick(self): if self._asked_for_local and ( not self.update_game_installation_status_task or self.update_game_installation_status_task.done()): self.update_game_installation_status_task = asyncio.create_task( self.update_game_installation_status()) if self._asked_for_local and ( not self.update_game_running_status_task or self.update_game_running_status_task.done()): self.update_game_running_status_task = asyncio.create_task( self.update_game_running_status()) if self.owned_games_cache and (not self.check_for_new_games_task or self.check_for_new_games_task.done()): self.check_for_new_games_task = asyncio.create_task( self.check_for_new_games()) def shutdown(self): asyncio.create_task(self._http_client.close())