예제 #1
0
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()
예제 #2
0
 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)
예제 #3
0
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
예제 #5
0
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()
예제 #6
0
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()
예제 #7
0
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()
예제 #10
0
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())
예제 #11
0
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())
예제 #12
0
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())