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())