Exemplo n.º 1
0
class OriginPlugin(Plugin):
    # pylint: disable=abstract-method
    def __init__(self, reader, writer, token):
        super().__init__(Platform.Origin, __version__, reader, writer, token)
        self._pid = None
        self._persona_id = None
        self._offer_id_cache = {}
        self._game_time_cache: Dict[OfferId, GameTime] = {}
        self._last_played_games: Dict[MasterTitleId, Timestamp] = {}

        self._local_games = LocalGames(get_local_content_path())
        self._local_games_last_update = 0
        self._local_games_update_in_progress = False

        def auth_lost():
            self.lost_authentication()

        self._http_client = AuthenticatedHttpClient()
        self._http_client.set_auth_lost_callback(auth_lost)
        self._http_client.set_cookies_updated_callback(
            self._update_stored_cookies)
        self._backend_client = OriginBackendClient(self._http_client)

    def shutdown(self):
        asyncio.create_task(self._http_client.close())

    def tick(self):
        self.handle_local_game_update_notifications()

    async def _do_authenticate(self, cookies):
        try:
            await self._http_client.authenticate(cookies)

            self._pid, self._persona_id, user_name = await self._backend_client.get_identity(
            )
            return Authentication(self._pid, user_name)

        except (BackendNotAvailable, BackendTimeout, BackendError):
            raise
        except Exception:
            # TODO: more precise error reason
            logging.exception("Authentication failed")
            raise InvalidCredentials()

    async def authenticate(self, stored_credentials=None):
        stored_cookies = stored_credentials.get(
            "cookies") if stored_credentials else None

        if not stored_cookies:
            return NextStep("web_session", AUTH_PARAMS)

        return await self._do_authenticate(stored_cookies)

    async def pass_login_credentials(self, step, credentials, cookies):
        new_cookies = {cookie["name"]: cookie["value"] for cookie in cookies}
        auth_info = await self._do_authenticate(new_cookies)
        self._store_cookies(new_cookies)
        return auth_info

    async def get_owned_games(self):
        if not self._http_client.is_authenticated():
            raise AuthenticationRequired()

        owned_offers = await self._get_owned_offers()

        games = []
        for offer in owned_offers:
            game = Game(offer["offerId"], offer["i18n"]["displayName"], None,
                        LicenseInfo(LicenseType.SinglePurchase, None))
            games.append(game)

        return games

    # Left for backward compatibility, until feature detection uses transactional methods
    async def get_unlocked_achievements(self, game_id):
        if not self._http_client.is_authenticated():
            raise AuthenticationRequired()

        try:
            achievement_set = (await self._get_offers(
                [game_id]))[0]["platforms"][0]["achievementSetOverride"]
            if achievement_set is None:
                return []

            achievements = await self._backend_client.get_achievements(
                self._persona_id, {game_id: achievement_set})

            return [
                Achievement(achievement_id=key,
                            achievement_name=value["name"],
                            unlock_time=value["u"])
                for key, value in achievements[game_id].items()
                if value["complete"]
            ]
        except (KeyError, IndexError):
            raise UnknownBackendResponse()

    async def start_achievements_import(self, game_ids):
        if not self._http_client.is_authenticated():
            raise AuthenticationRequired

        await super().start_achievements_import(game_ids)

    async def import_games_achievements(self, _game_ids):
        game_ids = set(_game_ids)
        error = UnknownError()
        try:
            achievement_sets = {
            }  # 'offerId' to 'achievementSet' names mapping
            for offer_id, achievement_set in (
                    await
                    self._backend_client.get_achievements_sets(self._persona_id
                                                               )).items():
                if not achievement_set:
                    self.game_achievements_import_success(offer_id, [])
                    game_ids.remove(offer_id)
                else:
                    achievement_sets[offer_id] = achievement_set

            if not achievement_sets:
                return

            for offer_id, achievements in (
                    await self._backend_client.get_achievements(
                        self._persona_id, achievement_sets)).items():
                try:
                    self.game_achievements_import_success(
                        offer_id, [
                            Achievement(achievement_id=key,
                                        achievement_name=value["name"],
                                        unlock_time=value["u"])
                            for key, value in achievements.items()
                            if value["complete"]
                        ])
                except KeyError:
                    self.game_achievements_import_failure(
                        offer_id, UnknownBackendResponse())
                except ApplicationError as error:
                    self.game_achievements_import_failure(offer_id, error)
                finally:
                    game_ids.remove(offer_id)
        except KeyError:
            error = UnknownBackendResponse()
        except ApplicationError as _error:
            error = _error
        except Exception:
            pass  # handled below
        finally:
            # any other exceptions or not answered game_ids are responded with an error
            [
                self.game_achievements_import_failure(game_id, error)
                for game_id in game_ids
            ]

    async def _get_offers(self, offer_ids):
        """
            Get offers from cache if exists.
            Fetch from backend if not and update cache.
        """
        offers = []
        missing_offers = []
        for offer_id in offer_ids:
            offer = self._offer_id_cache.get(offer_id, None)
            if offer is not None:
                offers.append(offer)
            else:
                missing_offers.append(offer_id)

        # request for missing offers
        if missing_offers:
            requests = [
                self._backend_client.get_offer(offer_id)
                for offer_id in missing_offers
            ]
            new_offers = await asyncio.gather(*requests)

            # update
            for offer in new_offers:
                offer_id = offer["offerId"]
                offers.append(offer)
                self._offer_id_cache[offer_id] = offer

        return offers

    async def _get_owned_offers(self):
        entitlements = await self._backend_client.get_entitlements(self._pid)

        # filter
        entitlements = [
            x for x in entitlements if x["offerType"] == "basegame"
        ]

        # check if we have offers in cache
        offer_ids = [entitlement["offerId"] for entitlement in entitlements]
        return await self._get_offers(offer_ids)

    async def get_local_games(self):
        if self._local_games_update_in_progress:
            logging.debug(
                "LocalGames.update in progress, returning cached values")
            return self._local_games.local_games

        loop = asyncio.get_running_loop()
        try:
            self._local_games_update_in_progress = True
            local_games, _ = await loop.run_in_executor(
                None, partial(LocalGames.update, self._local_games))
            self._local_games_last_update = time.time()
        finally:
            self._local_games_update_in_progress = False
        return local_games

    def handle_local_game_update_notifications(self):
        async def notify_local_games_changed():
            notify_list = []
            try:
                self._local_games_update_in_progress = True
                _, notify_list = await loop.run_in_executor(
                    None, partial(LocalGames.update, self._local_games))
                self._local_games_last_update = time.time()
            finally:
                self._local_games_update_in_progress = False

            for local_game_notify in notify_list:
                self.update_local_game_status(local_game_notify)

        # don't overlap update operations
        if self._local_games_update_in_progress:
            logging.debug(
                "LocalGames.update in progress, skipping cache update")
            return

        if time.time(
        ) - self._local_games_last_update < LOCAL_GAMES_CACHE_VALID_PERIOD:
            logging.debug("Local games cache is fresh enough")
            return

        loop = asyncio.get_running_loop()
        asyncio.create_task(notify_local_games_changed())

    @staticmethod
    def _get_multiplayer_id(offer) -> Optional[MultiplayerId]:
        for game_platform in offer["platforms"]:
            multiplayer_id = game_platform["multiPlayerId"]
            if multiplayer_id is not None:
                return multiplayer_id
        return None

    async def _get_game_times_for_offer(
            self, offer_id: OfferId, master_title_id: MasterTitleId,
            multiplayer_id: Optional[MultiplayerId],
            lastplayed_time: Optional[Timestamp]) -> GameTime:
        # returns None if a new entry should be retrieved
        def get_cached_game_times(
                _offer_id: OfferId,
                _lastplayed_time: Optional[Timestamp]) -> Optional[GameTime]:
            if _lastplayed_time is None:
                # double-check if 'lastplayed_time' is unknown (maybe it was just to long ago)
                return None

            _cached_game_time: GameTime = self._game_time_cache.get(offer_id)
            if _cached_game_time is None or _cached_game_time.last_played_time is None:
                # played time unknown yet
                return None
            if _lastplayed_time > _cached_game_time.last_played_time:
                # newer played time available
                return None
            return _cached_game_time

        cached_game_time: Optional[GameTime] = get_cached_game_times(
            offer_id, lastplayed_time)
        if cached_game_time is not None:
            return cached_game_time

        response = await self._backend_client.get_game_time(
            self._pid, master_title_id, multiplayer_id)
        game_time: GameTime = GameTime(offer_id, response[0], response[1])
        self._game_time_cache[offer_id] = game_time
        return game_time

    async def get_game_times(self):
        if not self._http_client.is_authenticated():
            raise AuthenticationRequired()

        owned_offers, last_played_games = await asyncio.gather(
            self._get_owned_offers(),
            self._backend_client.get_lastplayed_games(self._pid))

        requests = []
        try:
            for offer in owned_offers:
                master_title_id = offer["masterTitleId"]

                requests.append(
                    self._get_game_times_for_offer(
                        offer_id=offer["offerId"],
                        master_title_id=master_title_id,
                        multiplayer_id=self._get_multiplayer_id(offer),
                        lastplayed_time=last_played_games.get(
                            master_title_id)))
        except KeyError:
            raise UnknownBackendResponse()

        return await asyncio.gather(*requests)

    async def start_game_times_import(self, game_ids):
        if not self._http_client.is_authenticated():
            raise AuthenticationRequired()

        _, self._last_played_games = await asyncio.gather(
            self._get_offers(
                game_ids),  # update local cache ignoring return value
            self._backend_client.get_lastplayed_games(self._pid))

        await super().start_game_times_import(game_ids)

    async def import_game_times(self, game_ids: List[OfferId]):
        async def import_game_time(offer_id: OfferId):
            try:
                offer = self._offer_id_cache.get(offer_id)
                if offer is None:
                    raise Exception("Internal cache out of sync")
                master_title_id: MasterTitleId = offer["masterTitleId"]
                multiplayer_id: Optional[
                    MultiplayerId] = self._get_multiplayer_id(offer)

                self.game_time_import_success(
                    await self._get_game_times_for_offer(
                        offer_id, master_title_id, multiplayer_id,
                        self._last_played_games.get(master_title_id)))
            except KeyError:
                self.game_time_import_failure(offer_id,
                                              UnknownBackendResponse())
            except ApplicationError as error:
                self.game_time_import_failure(offer_id, error)
            except Exception:
                logging.exception(
                    "Unhandled exception. Please report it to the plugin developers"
                )
                self.game_time_import_failure(offer_id, UnknownError())

        await asyncio.gather(
            *[import_game_time(offer_id) for offer_id in game_ids])
        self._last_played_games = None

    async def get_friends(self):
        if not self._http_client.is_authenticated():
            raise AuthenticationRequired()

        return [
            FriendInfo(user_id=str(user_id), user_name=str(user_name))
            for user_id, user_name in (
                await self._backend_client.get_friends(self._pid)).items()
        ]

    @staticmethod
    async def _open_uri(uri):
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, partial(webbrowser.open, uri))

    async def launch_game(self, game_id):
        if is_uri_handler_installed("origin2"):
            await OriginPlugin._open_uri(
                "origin2://game/launch?offerIds={}&autoDownload=true".format(
                    game_id))
        else:
            await OriginPlugin._open_uri("https://www.origin.com/download")

    async def install_game(self, game_id):
        if is_uri_handler_installed("origin2"):
            await OriginPlugin._open_uri(
                "origin2://game/download?offerId={}".format(game_id))
        else:
            await OriginPlugin._open_uri("https://www.origin.com/download")

    if is_windows():

        async def uninstall_game(self, game_id):
            loop = asyncio.get_running_loop()
            await loop.run_in_executor(
                None, partial(subprocess.run, ["control", "appwiz.cpl"]))

    def _store_cookies(self, cookies):
        self.store_credentials({"cookies": cookies})

    def _update_stored_cookies(self, morsels):
        cookies = {}
        for morsel in morsels:
            cookies[morsel.key] = morsel.value
        self._store_cookies(cookies)
class SteamPlugin(Plugin):
    def __init__(self, reader, writer, token):
        super().__init__(Platform.Steam, __version__, reader, writer, token)
        self._regmon = get_steam_registry_monitor()
        self._local_games_cache: Optional[List[LocalGame]] = None
        self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
        self._ssl_context.load_verify_locations(certifi.where())
        self._http_client = AuthenticatedHttpClient()
        self._client = SteamHttpClient(self._http_client)
        self._persistent_storage_state = PersistentCacheState()
        self._servers_cache = ServersCache(self._client, self._ssl_context,
                                           self.persistent_cache,
                                           self._persistent_storage_state)
        self._friends_cache = FriendsCache()
        self._games_cache = GamesCache()
        self._translations_cache = dict()
        self._stats_cache = StatsCache()
        self._user_info_cache = UserInfoCache()
        self._times_cache = TimesCache()
        self._steam_client = WebSocketClient(
            self._client, self._ssl_context, self._servers_cache,
            self._friends_cache, self._games_cache, self._translations_cache,
            self._stats_cache, self._times_cache, self._user_info_cache,
            self.store_credentials)
        self._steam_client_run_task = None

        self._tags_semaphore = asyncio.Semaphore(5)

        self._library_settings_import_iterator = 0
        self._last_launch: Timestamp = 0

        self._update_local_games_task = asyncio.create_task(asyncio.sleep(0))
        self._update_owned_games_task = asyncio.create_task(asyncio.sleep(0))
        self._owned_games_parsed = None

        self._auth_data = None
        self._cooldown_timer = time.time()

        async def user_presence_update_handler(user_id: str,
                                               proto_user_info: ProtoUserInfo):
            self.update_user_presence(
                user_id, await
                presence_from_user_info(proto_user_info,
                                        self._translations_cache))

        self._friends_cache.updated_handler = user_presence_update_handler

    # TODO: Remove - Steamcommunity auth element
    def _store_cookies(self, cookies):
        credentials = {"cookies": morsels_to_dicts(cookies)}
        self.store_credentials(credentials)

    # TODO: Remove - Steamcommunity auth element
    def _force_utc(self):
        cookies = SimpleCookie()
        cookies["timezoneOffset"] = "0,0"
        morsel = cookies["timezoneOffset"]
        morsel["domain"] = "steamcommunity.com"
        # override encoding (steam does not fallow RFC 6265)
        morsel.set("timezoneOffset", "0,0", "0,0")
        self._http_client.update_cookies(cookies)

    async def shutdown(self):
        self._regmon.close()
        await self._steam_client.close()
        await self._http_client.close()
        await self._steam_client.wait_closed()

        with suppress(asyncio.CancelledError):
            self._update_local_games_task.cancel()
            self._update_owned_games_task.cancel()
            await self._update_local_games_task
            await self._update_owned_games_task

    async def _authenticate(self,
                            username=None,
                            password=None,
                            two_factor=None):
        if two_factor:
            return await self._steam_client.communication_queues[
                'websocket'].put({
                    'password': password,
                    'two_factor': two_factor
                })
        if not username or not password:
            raise UnknownBackendResponse()
        self._user_info_cache.account_username = username
        await self._steam_client.communication_queues['websocket'].put(
            {'password': password})

    # TODO: Remove - Steamcommunity auth element
    async def _do_steamcommunity_auth(self, morsels):
        cookies = [(morsel.key, morsel) for morsel in morsels]

        self._http_client.update_cookies(cookies)
        self._http_client.set_cookies_updated_callback(self._store_cookies)
        self._force_utc()

        try:
            profile_url = await self._client.get_profile()
        except UnknownBackendResponse:
            raise InvalidCredentials()

        async def set_profile_data():
            try:
                await self._client.get_authentication_data()
                steam_id, login = await self._client.get_profile_data(
                    profile_url)
                self._user_info_cache.account_username = login
                self._user_info_cache.old_flow = True
                self._user_info_cache.steam_id = steam_id
                self.create_task(self._steam_client.run(),
                                 "Run WebSocketClient")
                return steam_id, login
            except AccessDenied:
                raise InvalidCredentials()

        try:
            steam_id, login = await set_profile_data()
        except UnfinishedAccountSetup:
            await self._client.setup_steam_profile(profile_url)
            steam_id, login = await set_profile_data()

        self._http_client.set_auth_lost_callback(self.lost_authentication)

        if "steamRememberLogin" in (cookie[0] for cookie in cookies):
            logging.debug("Remember login cookie present")
        else:
            logging.debug("Remember login cookie not present")

        return Authentication(steam_id, login)

    async def cancel_task(self, task):
        try:
            task.cancel()
            await task
        except asyncio.CancelledError:
            pass

    async def authenticate(self, stored_credentials=None):
        if not stored_credentials:
            self.create_task(self._steam_client.run(), "Run WebSocketClient")
            return next_step_response(START_URI.LOGIN, END_URI.LOGIN_FINISHED)

        # TODO remove at some point, old refresh flow
        cookies = stored_credentials.get("cookies", [])
        if cookies:
            morsels = parse_stored_cookies(cookies)
            return await self._do_steamcommunity_auth(morsels)

        self._user_info_cache.from_dict(stored_credentials)
        if 'games' in self.persistent_cache:
            self._games_cache.loads(self.persistent_cache['games'])

        steam_run_task = self.create_task(self._steam_client.run(),
                                          "Run WebSocketClient")
        connection_timeout = 30
        try:
            await asyncio.wait_for(self._user_info_cache.initialized.wait(),
                                   connection_timeout)
        except asyncio.TimeoutError:
            try:
                self.raise_websocket_errors()
            except BackendError as e:
                logging.info(
                    f"Unable to keep connection with steam backend {repr(e)}")
            except Exception as e:
                logging.info(
                    f"Internal websocket exception caught during auth {repr(e)}"
                )
                await self.cancel_task(steam_run_task)
                raise
            logging.info(
                f"Failed to initialize connection with steam client within {connection_timeout} seconds"
            )
            await self.cancel_task(steam_run_task)
            raise BackendTimeout()
        self.store_credentials(self._user_info_cache.to_dict())
        return Authentication(self._user_info_cache.steam_id,
                              self._user_info_cache.persona_name)

    async def _get_websocket_auth_step(self):
        try:
            result = await asyncio.wait_for(
                self._steam_client.communication_queues['plugin'].get(), 60)
            result = result['auth_result']
        except asyncio.TimeoutError:
            self.raise_websocket_errors()
            raise BackendTimeout()
        return result

    async def _handle_login_finished(self, credentials):
        parsed_url = parse.urlsplit(credentials['end_uri'])

        params = parse.parse_qs(parsed_url.query)
        if 'username' not in params or 'password' not in params:
            return next_step_response(START_URI.LOGIN_FAILED,
                                      END_URI.LOGIN_FINISHED)

        username = params['username'][0]
        password = params['password'][0]
        self._user_info_cache.account_username = username
        self._auth_data = [username, password]
        await self._steam_client.communication_queues['websocket'].put(
            {'password': password})
        result = await self._get_websocket_auth_step()
        if result == UserActionRequired.NoActionRequired:
            self._auth_data = None
            self.store_credentials(self._user_info_cache.to_dict())
            return Authentication(self._user_info_cache.steam_id,
                                  self._user_info_cache.persona_name)
        if result == UserActionRequired.EmailTwoFactorInputRequired:
            return next_step_response(START_URI.TWO_FACTOR_MAIL,
                                      END_URI.TWO_FACTOR_MAIL_FINISHED)
        if result == UserActionRequired.PhoneTwoFactorInputRequired:
            return next_step_response(START_URI.TWO_FACTOR_MOBILE,
                                      END_URI.TWO_FACTOR_MOBILE_FINISHED)
        else:
            return next_step_response(START_URI.LOGIN_FAILED,
                                      END_URI.LOGIN_FINISHED)

    async def _handle_two_step(self, params, fail, finish):
        if 'code' not in params:
            return next_step_response(fail, finish)

        two_factor = params['code'][0]
        await self._steam_client.communication_queues['websocket'].put({
            'password':
            self._auth_data[1],
            'two_factor':
            two_factor
        })
        result = await self._get_websocket_auth_step()
        logger.info(f'2fa result {result}')
        if result != UserActionRequired.NoActionRequired:
            return next_step_response(fail, finish)
        else:
            self._auth_data = None
            self.store_credentials(self._user_info_cache.to_dict())
            return Authentication(self._user_info_cache.steam_id,
                                  self._user_info_cache.persona_name)

    async def _handle_two_step_mobile_finished(self, credentials):
        parsed_url = parse.urlsplit(credentials['end_uri'])
        params = parse.parse_qs(parsed_url.query)
        return await self._handle_two_step(params,
                                           START_URI.TWO_FACTOR_MOBILE_FAILED,
                                           END_URI.TWO_FACTOR_MOBILE_FINISHED)

    async def _handle_two_step_email_finished(self, credentials):
        parsed_url = parse.urlsplit(credentials['end_uri'])
        params = parse.parse_qs(parsed_url.query)

        if 'resend' in params:
            await self._steam_client.communication_queues['websocket'].put(
                {'password': self._auth_data[1]})
            await self._get_websocket_auth_step()  # Clear the queue
            return next_step_response(START_URI.TWO_FACTOR_MAIL,
                                      END_URI.TWO_FACTOR_MAIL_FINISHED)

        return await self._handle_two_step(params,
                                           START_URI.TWO_FACTOR_MAIL_FAILED,
                                           END_URI.TWO_FACTOR_MAIL_FINISHED)

    async def pass_login_credentials(self, step, credentials, cookies):
        if 'login_finished' in credentials['end_uri']:
            return await self._handle_login_finished(credentials)
        if 'two_factor_mobile_finished' in credentials['end_uri']:
            return await self._handle_two_step_mobile_finished(credentials)
        if 'two_factor_mail_finished' in credentials['end_uri']:
            return await self._handle_two_step_email_finished(credentials)

    async def get_owned_games(self):
        if self._user_info_cache.steam_id is None:
            raise AuthenticationRequired()

        await self._games_cache.wait_ready(90)
        owned_games = []
        self._games_cache.add_game_lever = True

        try:
            for game_id, game_title in self._games_cache:
                owned_games.append(
                    Game(str(game_id), game_title, [],
                         LicenseInfo(LicenseType.SinglePurchase, None)))
        except (KeyError, ValueError):
            logger.exception("Can not parse backend response")
            raise UnknownBackendResponse()
        finally:
            self._owned_games_parsed = True
        self.persistent_cache['games'] = self._games_cache.dump()
        self.push_cache()

        return owned_games

    async def prepare_achievements_context(self, game_ids: List[str]) -> Any:
        if self._user_info_cache.steam_id is None:
            raise AuthenticationRequired()

        if not self._stats_cache.import_in_progress:
            await self._steam_client.refresh_game_stats(game_ids.copy())
        else:
            logger.info("Game stats import already in progress")
        await self._stats_cache.wait_ready(
            10 * 60
        )  # Don't block future imports in case we somehow don't receive one of the responses
        logger.info("Finished achievements context prepare")

    async def get_unlocked_achievements(self, game_id: str,
                                        context: Any) -> List[Achievement]:
        logger.info(f"Asked for achievs for {game_id}")
        game_stats = self._stats_cache.get(game_id)
        achievements = []
        if game_stats:
            if 'achievements' not in game_stats:
                return []
            for achievement in game_stats['achievements']:

                # Fix for trailing whitespace in some achievement names which resulted in achievements not matching with website data
                achi_name = achievement['name']
                achi_name = achi_name.strip()
                if not achi_name:
                    achi_name = achievement['name']

                achievements.append(
                    Achievement(achievement['unlock_time'],
                                achievement_id=None,
                                achievement_name=achi_name))
        return achievements

    async def prepare_game_times_context(self, game_ids: List[str]) -> Any:
        if self._user_info_cache.steam_id is None:
            raise AuthenticationRequired()

        if not self._times_cache.import_in_progress:
            await self._steam_client.refresh_game_times()
        else:
            logger.info("Game stats import already in progress")
        await self._times_cache.wait_ready(
            10 * 60
        )  # Don't block future imports in case we somehow don't receive one of the responses
        logger.info("Finished game times context prepare")

    async def get_game_time(self, game_id: str,
                            context: Dict[int, int]) -> GameTime:
        time_played = self._times_cache.get(game_id, {}).get('time_played')
        last_played = self._times_cache.get(game_id, {}).get('last_played')
        if last_played == 86400:
            last_played = None
        return GameTime(game_id, time_played, last_played)

    async def prepare_game_library_settings_context(
            self, game_ids: List[str]) -> Any:
        if self._user_info_cache.steam_id is None:
            raise AuthenticationRequired()

        return await self._steam_client.retrieve_collections()

    async def get_game_library_settings(self, game_id: str,
                                        context: Any) -> GameLibrarySettings:
        if not context:
            return GameLibrarySettings(game_id, None, None)
        else:
            game_in_collections = []
            hidden = False
            for collection_name in context:
                if int(game_id) in context[collection_name]:
                    if collection_name.lower() == 'hidden':
                        hidden = True
                    else:
                        game_in_collections.append(collection_name)

            return GameLibrarySettings(game_id, game_in_collections, hidden)

    async def get_friends(self):
        if self._user_info_cache.steam_id is None:
            raise AuthenticationRequired()

        friends_ids = await self._steam_client.get_friends()
        friends_infos = await self._steam_client.get_friends_info(friends_ids)
        friends_nicknames = await self._steam_client.get_friends_nicknames()

        friends = []
        for friend_id in friends_infos:
            friend = galaxy_user_info_from_user_info(str(friend_id),
                                                     friends_infos[friend_id])
            if str(friend_id) in friends_nicknames:
                friend.user_name += f" ({friends_nicknames[friend_id]})"
            friends.append(friend)
        return friends

    async def prepare_user_presence_context(self, user_ids: List[str]) -> Any:
        return await self._steam_client.get_friends_info(user_ids)

    async def get_user_presence(self, user_id: str,
                                context: Any) -> UserPresence:
        user_info = context.get(user_id)
        if user_info is None:
            raise UnknownError(
                "User {} not in friend list (plugin only supports fetching presence for friends)"
                .format(user_id))
        return await presence_from_user_info(user_info,
                                             self._translations_cache)

    async def _update_owned_games(self):
        new_games = self._games_cache.get_added_games()
        iter = 0
        for game in new_games:
            iter += 1
            self.add_game(
                Game(game,
                     new_games[game], [],
                     license_info=LicenseInfo(LicenseType.SinglePurchase)))
            self.persistent_cache['games'] = self._games_cache.dump()
            self.push_cache()
            if iter >= 5:
                iter = 0
                await asyncio.sleep(1)

    def raise_websocket_errors(self):
        try:
            result = self._steam_client.communication_queues[
                'errors'].get_nowait()
            if result and isinstance(result, Exception):
                raise result
        except asyncio.queues.QueueEmpty:
            pass

    def tick(self):
        if self._local_games_cache is not None and \
                (self._update_local_games_task is None or self._update_local_games_task.done()) and \
                self._regmon.is_updated():
            self._update_local_games_task = asyncio.create_task(
                self._update_local_games())
        if self._update_owned_games_task is None or self._update_owned_games_task.done(
        ) and self._owned_games_parsed:
            self._update_owned_games_task = asyncio.create_task(
                self._update_owned_games())

        if self._persistent_storage_state.modified:
            self.push_cache()
            self._persistent_storage_state.modified = False
        if self._user_info_cache.changed:
            self.store_credentials(self._user_info_cache.to_dict())

        if self._user_info_cache.initialized.is_set():
            self.raise_websocket_errors()

    async def _update_local_games(self):
        if time.time() < self._cooldown_timer:
            await asyncio.sleep(COOLDOWN_TIME)
        loop = asyncio.get_running_loop()
        new_list = await loop.run_in_executor(None, local_games_list)
        notify_list = get_state_changes(self._local_games_cache, new_list)
        self._local_games_cache = new_list
        for game in notify_list:
            if LocalGameState.Running in game.local_game_state:
                self._last_launch = time.time()
            self.update_local_game_status(game)
        self._cooldown_timer = time.time() + COOLDOWN_TIME

    async def get_local_games(self):
        loop = asyncio.get_running_loop()
        self._local_games_cache = await loop.run_in_executor(
            None, local_games_list)
        return self._local_games_cache

    @staticmethod
    def _steam_command(command, game_id):
        if is_uri_handler_installed("steam"):
            webbrowser.open("steam://{}/{}".format(command, game_id))
        else:
            webbrowser.open("https://store.steampowered.com/about/")

    async def launch_game(self, game_id):
        SteamPlugin._steam_command("launch", game_id)

    async def install_game(self, game_id):
        SteamPlugin._steam_command("install", game_id)

    async def uninstall_game(self, game_id):
        SteamPlugin._steam_command("uninstall", game_id)

    async def get_subscriptions(self) -> List[Subscription]:
        await self._games_cache.wait_ready(90)
        if self._games_cache.get_shared_games():
            return [
                Subscription("Family Sharing", True, None,
                             SubscriptionDiscovery.AUTOMATIC)
            ]
        return [
            Subscription("Family Sharing", False, None,
                         SubscriptionDiscovery.AUTOMATIC)
        ]

    async def prepare_subscription_games_context(
            self, subscription_names: List[str]) -> Any:
        return [
            SubscriptionGame(game_id=str(game['id']), game_title=game['title'])
            for game in self._games_cache.get_shared_games()
        ]

    async def get_subscription_games(
            self, subscription_name: str,
            context: Any) -> AsyncGenerator[List[SubscriptionGame], None]:
        yield context

    async def shutdown_platform_client(self) -> None:
        launch_debounce_time = 30
        if time.time() < self._last_launch + launch_debounce_time:
            # workaround for quickly closed game (Steam sometimes dumps false positive just after a launch)
            logging.info(
                'Ignoring shutdown request because game was launched a moment ago'
            )
            return
        if is_windows():
            exe = get_client_executable()
            if exe is None:
                return
            cmd = '"{}" -shutdown -silent'.format(exe)
        else:
            cmd = "osascript -e 'quit app \"Steam\"'"
        logger.debug("Running command '%s'", cmd)
        process = await asyncio.create_subprocess_shell(
            cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        await process.communicate()
Exemplo n.º 3
0
class SteamPlugin(Plugin):
    def __init__(self, reader, writer, token):
        super().__init__(Platform.Steam, __version__, reader, writer, token)
        self._steam_id = None
        self._miniprofile_id = None
        self._level_db_parser = None
        self._regmon = get_steam_registry_monitor()
        self._local_games_cache: Optional[List[LocalGame]] = None
        self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
        self._ssl_context.load_verify_locations(certifi.where())
        self._http_client = AuthenticatedHttpClient()
        self._client = SteamHttpClient(self._http_client)
        self._persistent_storage_state = PersistentCacheState()
        self._servers_cache = ServersCache(self._client, self._ssl_context,
                                           self.persistent_cache,
                                           self._persistent_storage_state)
        self._friends_cache = FriendsCache()
        self._games_cache = GamesCache()
        self._translations_cache = dict()
        self._steam_client = WebSocketClient(self._client, self._ssl_context,
                                             self._servers_cache,
                                             self._friends_cache,
                                             self._games_cache,
                                             self._translations_cache)
        self._achievements_cache = Cache()
        self._achievements_cache_updated = False

        self._achievements_semaphore = asyncio.Semaphore(20)
        self._tags_semaphore = asyncio.Semaphore(5)

        self._library_settings_import_iterator = 0
        self._last_launch: Timestamp = 0

        self._update_local_games_task = None
        self._update_owned_games_task = None
        self._owned_games_parsed = None

        def user_presence_update_handler(user_id: str, user_info: UserInfo):
            self.update_user_presence(
                user_id, from_user_info(user_info, self._translations_cache))

        self._friends_cache.updated_handler = user_presence_update_handler

    def _store_cookies(self, cookies):
        credentials = {"cookies": morsels_to_dicts(cookies)}
        self.store_credentials(credentials)

    @staticmethod
    def _create_two_factor_fake_cookie():
        return Cookie(
            # random SteamID with proper "instance", "type" and "universe" fields
            # (encoded in most significant bits)
            name="steamMachineAuth{}".format(
                random.randint(1, 2**32 - 1) + 0x01100001 * 2**32),
            # 40-bit random string encoded as hex
            value=hex(random.getrandbits(20 * 8))[2:].upper())

    async def shutdown(self):
        self._regmon.close()
        await self._steam_client.close()
        await self._http_client.close()
        await self._steam_client.wait_closed()

    def handshake_complete(self):
        achievements_cache_ = self.persistent_cache.get("achievements")
        if achievements_cache_ is not None:
            try:
                achievements_cache_ = json.loads(achievements_cache_)
                self._achievements_cache = achievements_cache.from_dict(
                    achievements_cache_)
            except Exception:
                logger.exception("Can not deserialize achievements cache")

    async def _do_auth(self, morsels):
        cookies = [(morsel.key, morsel) for morsel in morsels]

        self._http_client.update_cookies(cookies)
        self._http_client.set_cookies_updated_callback(self._store_cookies)
        self._force_utc()

        try:
            profile_url = await self._client.get_profile()
        except UnknownBackendResponse:
            raise InvalidCredentials()

        async def set_profile_data(profile_url):
            try:
                self._steam_id, self._miniprofile_id, login = await self._client.get_profile_data(
                    profile_url)
                self.create_task(self._steam_client.run(),
                                 "Run WebSocketClient")
                return login
            except AccessDenied:
                raise InvalidCredentials()

        try:
            login = await set_profile_data(profile_url)
        except UnfinishedAccountSetup:
            await self._client.setup_steam_profile(profile_url)
            login = await set_profile_data(profile_url)

        self._http_client.set_auth_lost_callback(self.lost_authentication)

        if "steamRememberLogin" in (cookie[0] for cookie in cookies):
            logging.debug("Remember login cookie present")
        else:
            logging.debug("Remember login cookie not present")

        return Authentication(self._steam_id, login)

    def _force_utc(self):
        cookies = SimpleCookie()
        cookies["timezoneOffset"] = "0,0"
        morsel = cookies["timezoneOffset"]
        morsel["domain"] = "steamcommunity.com"
        # override encoding (steam does not fallow RFC 6265)
        morsel.set("timezoneOffset", "0,0", "0,0")
        self._http_client.update_cookies(cookies)

    async def authenticate(self, stored_credentials=None):
        if not stored_credentials:
            if await self._client.get_steamcommunity_response_status() != 200:
                logger.error("Steamcommunity website not accessible")
            return NextStep("web_session", AUTH_PARAMS,
                            [self._create_two_factor_fake_cookie()],
                            {re.escape(LOGIN_URI): [JS_PERSISTENT_LOGIN]})

        cookies = stored_credentials.get("cookies", [])
        morsels = parse_stored_cookies(cookies)
        return await self._do_auth(morsels)

    async def pass_login_credentials(self, step, credentials, cookies):
        try:
            morsels = dicts_to_morsels(cookies)
        except Exception:
            raise InvalidParams()

        auth_info = await self._do_auth(morsels)
        self._store_cookies(morsels)
        return auth_info

    async def get_owned_games(self):
        if self._steam_id is None:
            raise AuthenticationRequired()

        await self._games_cache.wait_ready(90)
        owned_games = []
        self._games_cache.add_game_lever = True
        try:
            for game_id, game_title in self._games_cache:
                owned_games.append(
                    Game(str(game_id), game_title, [],
                         LicenseInfo(LicenseType.SinglePurchase, None)))
        except (KeyError, ValueError):
            logger.exception("Can not parse backend response")
            raise UnknownBackendResponse()
        finally:
            self._owned_games_parsed = True

        return owned_games

    async def prepare_game_times_context(self, game_ids: List[str]) -> Any:
        if self._steam_id is None:
            raise AuthenticationRequired()

        return await self._get_game_times_dict()

    async def get_game_time(self, game_id: str, context: Any) -> GameTime:
        game_time = context.get(game_id)
        if game_time is None:
            raise UnknownError("Game {} not owned".format(game_id))
        return game_time

    async def _get_game_times_dict(self) -> Dict[str, GameTime]:
        games = await self._client.get_games(self._steam_id)

        game_times = {}

        try:
            for game in games:
                game_id = str(game["appid"])
                last_played = game.get("last_played")
                if last_played == 86400:
                    # 86400 is used as sentinel value for games no supporting last_played
                    last_played = None
                game_times[game_id] = GameTime(
                    game_id,
                    int(
                        float(game.get("hours_forever", "0").replace(",", ""))
                        * 60), last_played)
        except (KeyError, ValueError):
            logger.exception("Can not parse backend response")
            raise UnknownBackendResponse()

        return game_times

    async def prepare_game_library_settings_context(
            self, game_ids: List[str]) -> Any:
        if self._steam_id is None:
            raise AuthenticationRequired()

        if not self._level_db_parser:
            self._level_db_parser = LevelDbParser(self._miniprofile_id)

        self._level_db_parser.parse_leveldb()

        if not self._level_db_parser.lvl_db_is_present:
            return None
        else:
            leveldb_static_games_collections_dict = self._level_db_parser.get_static_collections_tags(
            )
            logger.info(
                f"Leveldb static settings dict {leveldb_static_games_collections_dict}"
            )
            return leveldb_static_games_collections_dict

    async def get_game_library_settings(self, game_id: str,
                                        context: Any) -> GameLibrarySettings:
        if not context:
            return GameLibrarySettings(game_id, None, None)
        else:
            game_tags = context.get(game_id)
            if not game_tags:
                return GameLibrarySettings(game_id, [], False)

            hidden = False
            for tag in game_tags:
                if tag.lower() == 'hidden':
                    hidden = True
            if hidden:
                game_tags.remove('hidden')
            return GameLibrarySettings(game_id, game_tags, hidden)

    async def prepare_achievements_context(self, game_ids: List[str]) -> Any:
        if self._steam_id is None:
            raise AuthenticationRequired()

        return await self._get_game_times_dict()

    async def get_unlocked_achievements(self, game_id: str,
                                        context: Any) -> List[Achievement]:
        game_time = await self.get_game_time(game_id, context)

        fingerprint = achievements_cache.Fingerprint(
            game_time.last_played_time, game_time.time_played)
        achievements = self._achievements_cache.get(game_id, fingerprint)

        if achievements is not None:
            # return from cache
            return achievements

        # fetch from backend and update cache
        achievements = await self._get_achievements(game_id)
        self._achievements_cache.update(game_id, achievements, fingerprint)
        self._achievements_cache_updated = True
        return achievements

    def achievements_import_complete(self) -> None:
        if self._achievements_cache_updated:
            self._persistent_storage_state.modified = True
            self._achievements_cache_updated = False

    async def _get_achievements(self, game_id):
        async with self._achievements_semaphore:
            achievements = await self._client.get_achievements(
                self._steam_id, game_id)
            return [
                Achievement(unlock_time, None, name)
                for unlock_time, name in achievements
            ]

    async def get_friends(self):
        if self._steam_id is None:
            raise AuthenticationRequired()

        return await self._client.get_friends(self._steam_id)

    async def prepare_user_presence_context(self, user_ids: List[str]) -> Any:
        return await self._steam_client.get_friends_info(user_ids)

    async def get_user_presence(self, user_id: str,
                                context: Any) -> UserPresence:
        user_info = context.get(user_id)
        if user_info is None:
            raise UnknownError(
                "User {} not in friend list (plugin only supports fetching presence for friends)"
                .format(user_id))

        return from_user_info(user_info, self._translations_cache)

    async def _update_owned_games(self):
        new_games = self._games_cache.get_added_games()
        iter = 0
        for game in new_games:
            iter += 1
            self.add_game(
                Game(game,
                     new_games[game], [],
                     license_info=LicenseInfo(LicenseType.SinglePurchase)))
            if iter >= 5:
                iter = 0
                await asyncio.sleep(1)

    def tick(self):
        if self._local_games_cache is not None and \
                (self._update_local_games_task is None or self._update_local_games_task.done()) and \
                self._regmon.is_updated():
            self._update_local_games_task = self.create_task(
                self._update_local_games(), "Update local games")
        if self._update_owned_games_task is None or self._update_owned_games_task.done(
        ) and self._owned_games_parsed:
            self._update_owned_games_task = self.create_task(
                self._update_owned_games(), "Update owned games")

        if self._persistent_storage_state.modified:
            # serialize
            self.persistent_cache["achievements"] = achievements_cache.as_dict(
                self._achievements_cache)
            self.push_cache()
            self._persistent_storage_state.modified = False

    async def _update_local_games(self):
        loop = asyncio.get_running_loop()
        new_list = await loop.run_in_executor(None, local_games_list)
        notify_list = get_state_changes(self._local_games_cache, new_list)
        self._local_games_cache = new_list
        for game in notify_list:
            if LocalGameState.Running in game.local_game_state:
                self._last_launch = time.time()
            self.update_local_game_status(game)

    async def get_local_games(self):
        loop = asyncio.get_running_loop()
        self._local_games_cache = await loop.run_in_executor(
            None, local_games_list)
        return self._local_games_cache

    @staticmethod
    def _steam_command(command, game_id):
        if is_uri_handler_installed("steam"):
            webbrowser.open("steam://{}/{}".format(command, game_id))
        else:
            webbrowser.open("https://store.steampowered.com/about/")

    async def launch_game(self, game_id):
        SteamPlugin._steam_command("launch", game_id)

    async def install_game(self, game_id):
        SteamPlugin._steam_command("install", game_id)

    async def uninstall_game(self, game_id):
        SteamPlugin._steam_command("uninstall", game_id)

    async def shutdown_platform_client(self) -> None:
        launch_debounce_time = 3
        if time.time() < self._last_launch + launch_debounce_time:
            # workaround for quickly closed game (Steam sometimes dumps false positive just after a launch)
            logging.info(
                'Ignoring shutdown request because game was launched a moment ago'
            )
            return
        if is_windows():
            exe = get_client_executable()
            if exe is None:
                return
            cmd = '"{}" -shutdown -silent'.format(exe)
        else:
            cmd = "osascript -e 'quit app \"Steam\"'"
        logger.debug("Running command '%s'", cmd)
        process = await asyncio.create_subprocess_shell(
            cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        await process.communicate()
Exemplo n.º 4
0
class SteamPlugin(Plugin):
    def __init__(self, reader, writer, token):
        super().__init__(Platform.Steam, __version__, reader, writer, token)
        self._steam_id = None
        self._miniprofile_id = None
        self._own_games: List = []
        self._family_sharing_games: List[str] = []
        self._own_friends: List[FriendInfo] = []

        self._level_db_parser = None
        self._regmon = get_steam_registry_monitor()
        self._local_games_cache: List[LocalGame] = []
        self._http_client = AuthenticatedHttpClient()
        self._client = SteamHttpClient(self._http_client)
        self._achievements_cache = Cache()
        self._achievements_cache_updated = False

        self._achievements_semaphore = asyncio.Semaphore(20)
        self._tags_semaphore = asyncio.Semaphore(5)

        self._library_settings_import_iterator = 0
        self._game_tags_cache = {}

        self._update_task = self.create_task(self._update_local_games(),
                                             "Update local games")

    def _store_cookies(self, cookies):
        credentials = {"cookies": morsels_to_dicts(cookies)}
        self.store_credentials(credentials)

    @staticmethod
    def _create_two_factor_fake_cookie():
        return Cookie(
            # random SteamID with proper "instance", "type" and "universe" fields
            # (encoded in most significant bits)
            name="steamMachineAuth{}".format(
                random.randint(1, 2**32 - 1) + 0x01100001 * 2**32),
            # 40-bit random string encoded as hex
            value=hex(random.getrandbits(20 * 8))[2:].upper())

    async def shutdown(self):
        self._regmon.close()
        await self._http_client.close()

    def handshake_complete(self):
        achievements_cache_ = self.persistent_cache.get("achievements")
        if achievements_cache_ is not None:
            try:
                achievements_cache_ = json.loads(achievements_cache_)
                self._achievements_cache = achievements_cache.from_dict(
                    achievements_cache_)
            except Exception:
                logging.exception("Can not deserialize achievements cache")

    async def _do_auth(self, morsels):
        cookies = [(morsel.key, morsel) for morsel in morsels]

        self._http_client.update_cookies(cookies)
        self._http_client.set_cookies_updated_callback(self._store_cookies)
        self._force_utc()

        try:
            profile_url = await self._client.get_profile()
        except UnknownBackendResponse:
            raise InvalidCredentials()

        try:
            self._steam_id, self._miniprofile_id, login = await self._client.get_profile_data(
                profile_url)
        except AccessDenied:
            raise InvalidCredentials()

        self._http_client.set_auth_lost_callback(self.lost_authentication)

        return Authentication(self._steam_id, login)

    def _force_utc(self):
        cookies = SimpleCookie()
        cookies["timezoneOffset"] = "0,0"
        morsel = cookies["timezoneOffset"]
        morsel["domain"] = "steamcommunity.com"
        # override encoding (steam does not fallow RFC 6265)
        morsel.set("timezoneOffset", "0,0", "0,0")
        self._http_client.update_cookies(cookies)

    async def authenticate(self, stored_credentials=None):
        if not stored_credentials:
            return NextStep("web_session", AUTH_PARAMS,
                            [self._create_two_factor_fake_cookie()],
                            {re.escape(LOGIN_URI): [JS_PERSISTENT_LOGIN]})

        cookies = stored_credentials.get("cookies", [])
        morsels = parse_stored_cookies(cookies)
        return await self._do_auth(morsels)

    async def pass_login_credentials(self, step, credentials, cookies):
        try:
            morsels = dicts_to_morsels(cookies)
        except Exception:
            raise InvalidParams()

        auth_info = await self._do_auth(morsels)
        self._store_cookies(morsels)
        return auth_info

    async def get_owned_games(self):
        if self._steam_id is None:
            raise AuthenticationRequired()

        games = await self._client.get_games(self._steam_id)

        owned_games = []

        try:
            for game in games:
                owned_games.append(
                    Game(str(game["appid"]), game["name"], [],
                         LicenseInfo(LicenseType.SinglePurchase, None)))
        except (KeyError, ValueError):
            logging.exception("Can not parse backend response")
            raise UnknownBackendResponse()

        self._own_games = games

        game_ids = list(map(lambda x: x.game_id, owned_games))
        other_games = await self.get_steam_sharing_games(game_ids)
        for i in other_games:
            owned_games.append(i)

        return owned_games

    async def get_steam_sharing_games(self, owngames: List[str]) -> List[Game]:
        profiles = list(
            filter(
                lambda x: x.user_name.endswith(FRIEND_SHARING_END_PATTERN + "*"
                                               ), self._own_friends))
        newgames: List[Game] = []
        self._family_sharing_games = []
        for i in profiles:
            othergames = await self._client.get_games(i.user_id)

            try:
                for game in othergames:
                    hasit = any(f == str(game["appid"])
                                for f in owngames) or any(
                                    f.game_id == str(game["appid"])
                                    for f in newgames)
                    if not hasit:
                        self._family_sharing_games.append(str(game["appid"]))
                        newgame = Game(
                            str(game["appid"]), game["name"], [],
                            LicenseInfo(LicenseType.OtherUserLicense,
                                        i.user_name))
                        newgames.append(newgame)
            except (KeyError, ValueError):
                logging.exception("Can not parse backend response")
                raise UnknownBackendResponse()
        return newgames

    async def prepare_game_times_context(self, game_ids: List[str]) -> Any:
        if self._steam_id is None:
            raise AuthenticationRequired()

        return await self._get_game_times_dict()

    async def get_game_time(self, game_id: str, context: Any) -> GameTime:
        game_time = context.get(game_id)
        if game_time is None:
            logging.exception("Game {} not owned".format(game_id))
        return game_time

    async def _get_game_times_dict(self) -> Dict[str, GameTime]:
        games = self._own_games

        game_times = {}

        try:
            for game in games:
                game_id = str(game["appid"])
                last_played = game.get("last_played")
                if last_played == NO_LAST_PLAY:
                    last_played = None
                game_times[game_id] = GameTime(
                    game_id,
                    int(
                        float(game.get("hours_forever", "0").replace(",", ""))
                        * 60), last_played)
        except (KeyError, ValueError):
            logging.exception("Can not parse backend response")
            raise UnknownBackendResponse()

        try:
            steamFolder = get_configuration_folder()
            vdfFile = os.path.join(steamFolder, "userdata",
                                   self._miniprofile_id, "config",
                                   "localconfig.vdf")
            logging.debug(f"Users Localconfig.vdf {vdfFile}")
            data = load_vdf(vdfFile)
            timedata = data["UserLocalConfigStore"]["Software"]["Valve"][
                "Steam"]["Apps"]
            for gameid in self._family_sharing_games:
                playTime = 0
                lastPlayed = NO_LAST_PLAY
                if gameid in timedata:
                    item = timedata[gameid]
                    if 'playtime' in item:
                        playTime = item["playTime"]
                    if 'lastplayed' in item:
                        lastPlayed = item["LastPlayed"]
                game_times[gameid] = GameTime(gameid, playTime, lastPlayed)
        except (KeyError, ValueError):
            logging.exception("Can not parse friend games")

        return game_times

    async def prepare_game_library_settings_context(
            self, game_ids: List[str]) -> Any:
        if self._steam_id is None:
            raise AuthenticationRequired()

        if not self._level_db_parser:
            self._level_db_parser = LevelDbParser(self._miniprofile_id)

        self._level_db_parser.parse_leveldb()

        if not self._level_db_parser.lvl_db_is_present:
            return None
        else:
            leveldb_static_games_collections_dict = self._level_db_parser.get_static_collections_tags(
            )
            logging.info(
                f"Leveldb static settings dict {leveldb_static_games_collections_dict}"
            )
            return leveldb_static_games_collections_dict

    async def get_game_library_settings(self, game_id: str,
                                        context: Any) -> GameLibrarySettings:
        if not context:
            return GameLibrarySettings(game_id, None, None)
        else:
            game_tags = context.get(game_id)
            if not game_tags:
                return GameLibrarySettings(game_id, [], False)

            hidden = False
            for tag in game_tags:
                if tag.lower() == 'hidden':
                    hidden = True
            if hidden:
                game_tags.remove('hidden')
            return GameLibrarySettings(game_id, game_tags, hidden)

    async def prepare_achievements_context(self, game_ids: List[str]) -> Any:
        if self._steam_id is None:
            raise AuthenticationRequired()

        return await self._get_game_times_dict()

    async def get_unlocked_achievements(self, game_id: str,
                                        context: Any) -> List[Achievement]:
        game_time = await self.get_game_time(game_id, context)
        if game_time.time_played == 0:
            return []

        fingerprint = achievements_cache.Fingerprint(
            game_time.last_played_time, game_time.time_played)
        achievements = self._achievements_cache.get(game_id, fingerprint)

        if achievements is not None:
            # return from cache
            return achievements

        # fetch from backend and update cache
        achievements = await self._get_achievements(game_id)
        self._achievements_cache.update(game_id, achievements, fingerprint)
        self._achievements_cache_updated = True
        return achievements

    def achievements_import_complete(self) -> None:
        if self._achievements_cache_updated:
            self.push_cache()
            self._achievements_cache_updated = False

    async def _get_achievements(self, game_id):
        async with self._achievements_semaphore:
            achievements = await self._client.get_achievements(
                self._steam_id, game_id)
            return [
                Achievement(unlock_time, None, name)
                for unlock_time, name in achievements
            ]

    async def get_friends(self):
        if self._steam_id is None:
            raise AuthenticationRequired()

        self._own_friends = [
            FriendInfo(user_id=user_id, user_name=user_name)
            for user_id, user_name in (
                await self._client.get_friends(self._steam_id)).items()
        ]

        return self._own_friends

    def tick(self):
        if self._update_task.done() and self._regmon.is_updated():
            self._update_task = self.create_task(self._update_local_games(),
                                                 "Update local games")

    async def _update_local_games(self):
        loop = asyncio.get_running_loop()
        new_list = await loop.run_in_executor(None, local_games_list)
        notify_list = get_state_changes(self._local_games_cache, new_list)
        self._local_games_cache = new_list
        for local_game_notify in notify_list:
            self.update_local_game_status(local_game_notify)

    async def get_local_games(self):
        return self._local_games_cache

    @staticmethod
    def _steam_command(command, game_id):
        if is_uri_handler_installed("steam"):
            webbrowser.open("steam://{}/{}".format(command, game_id))
        else:
            webbrowser.open("https://store.steampowered.com/about/")

    async def launch_game(self, game_id):
        SteamPlugin._steam_command("launch", game_id)

    async def install_game(self, game_id):
        SteamPlugin._steam_command("install", game_id)

    async def uninstall_game(self, game_id):
        SteamPlugin._steam_command("uninstall", game_id)

    async def shutdown_platform_client(self) -> None:
        if is_windows():
            exe = get_client_executable()
            if exe is None:
                return
            cmd = '"{}" -shutdown -silent'.format(exe)
        else:
            cmd = "osascript -e 'quit app \"Steam\"'"
        logging.debug("Running command '%s'", cmd)
        process = await asyncio.create_subprocess_shell(
            cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        await process.communicate()
Exemplo n.º 5
0
class OriginPlugin(Plugin):
    def __init__(self, reader, writer, token):
        super().__init__(Platform.Origin, __version__, reader, writer, token)
        self._user_id = None
        self._persona_id = None

        self._local_games = LocalGames(get_local_content_path())
        self._local_games_last_update = 0
        self._local_games_update_in_progress = False

        def auth_lost():
            self.lost_authentication()

        self._http_client = AuthenticatedHttpClient()
        self._http_client.set_auth_lost_callback(auth_lost)
        self._http_client.set_cookies_updated_callback(
            self._update_stored_cookies)
        self._backend_client = OriginBackendClient(self._http_client)
        self._persistent_cache_updated = False

    @property
    def _game_time_cache(self) -> Dict[OfferId, GameTime]:
        return self.persistent_cache.setdefault("game_time", {})

    @property
    def _offer_id_cache(self):
        return self.persistent_cache.setdefault("offers", {})

    @property
    def _entitlement_cache(self):
        return self.persistent_cache.setdefault("entitlements", {})

    async def shutdown(self):
        await self._http_client.close()

    def tick(self):
        self.handle_local_game_update_notifications()

    def _check_authenticated(self):
        if not self._http_client.is_authenticated():
            logging.exception("Plugin not authenticated")
            raise AuthenticationRequired()

    async def _do_authenticate(self, cookies):
        try:
            await self._http_client.authenticate(cookies)

            self._user_id, self._persona_id, user_name = await self._backend_client.get_identity(
            )
            return Authentication(self._user_id, user_name)

        except (AccessDenied, InvalidCredentials, AuthenticationRequired) as e:
            logging.exception("Failed to authenticate %s", repr(e))
            raise InvalidCredentials()

    async def authenticate(self, stored_credentials=None):
        stored_cookies = stored_credentials.get(
            "cookies") if stored_credentials else None

        if not stored_cookies:
            return NextStep("web_session", AUTH_PARAMS, js=JS)

        return await self._do_authenticate(stored_cookies)

    async def pass_login_credentials(self, step, credentials, cookies):
        new_cookies = {cookie["name"]: cookie["value"] for cookie in cookies}
        auth_info = await self._do_authenticate(new_cookies)
        self._store_cookies(new_cookies)
        return auth_info

    async def get_owned_games(self):
        self._check_authenticated()

        owned_offers = await self._get_owned_offers()
        games = []
        for offer in owned_offers:
            game = Game(offer["offerId"], offer["i18n"]["displayName"], None,
                        LicenseInfo(LicenseType.SinglePurchase, None))
            games.append(game)

        return games

    @staticmethod
    def _get_achievement_set_override(offer) -> Optional[AchievementSet]:
        potential_achievement_set = None
        for achievement_set in offer["platforms"]:
            potential_achievement_set = achievement_set[
                "achievementSetOverride"]
            if achievement_set["platform"] == "PCWIN":
                return potential_achievement_set
        return potential_achievement_set

    async def prepare_achievements_context(self, game_ids: List[str]) -> Any:
        self._check_authenticated()
        owned_offers = await self._get_owned_offers()
        achievement_sets: Dict[OfferId, AchievementSet] = dict()
        for offer in owned_offers:
            achievement_sets[
                offer["offerId"]] = self._get_achievement_set_override(offer)
        return AchievementsImportContext(owned_games=achievement_sets,
                                         achievements=await
                                         self._backend_client.get_achievements(
                                             self._persona_id))

    async def get_unlocked_achievements(
            self, game_id: str,
            context: AchievementsImportContext) -> List[Achievement]:
        try:
            achievements_set = context.owned_games[game_id]
        except KeyError:
            logging.exception(
                "Game '{}' not found amongst owned".format(game_id))
            raise UnknownBackendResponse()

        if not achievements_set:
            return []

        try:
            # for some games(e.g.: ApexLegends) achievement set is not present in "all". have to fetch it explicitly
            achievements = context.achievements.get(achievements_set)
            if achievements is not None:
                return achievements

            return (await self._backend_client.get_achievements(
                self._persona_id, achievements_set))[achievements_set]

        except KeyError:
            logging.exception(
                "Failed to parse achievements for game {}".format(game_id))
            raise UnknownBackendResponse()

    async def _get_offers(self, offer_ids):
        """
            Get offers from cache if exists.
            Fetch from backend if not and update cache.
        """
        offers = []
        missing_offers = []
        for offer_id in offer_ids:
            offer = self._offer_id_cache.get(offer_id, None)
            if offer is not None:
                offers.append(offer)
            else:
                missing_offers.append(offer_id)

        # request for missing offers
        if missing_offers:
            requests = [
                self._backend_client.get_offer(offer_id)
                for offer_id in missing_offers
            ]
            new_offers = await asyncio.gather(*requests,
                                              return_exceptions=True)

            for offer in new_offers:
                if isinstance(offer, Exception):
                    logging.error(repr(offer))
                    continue
                offer_id = offer["offerId"]
                offers.append(offer)
                self._offer_id_cache[offer_id] = offer

            self.push_cache()

        return offers

    async def _get_owned_offers(self):
        entitlements = await self._backend_client.get_entitlements(
            self._user_id)

        for entitlement in entitlements:
            if entitlement['offerId'] not in self._entitlement_cache:
                self._entitlement_cache[entitlement["offerId"]] = entitlement

        # filter
        entitlements = [
            x for x in entitlements if x["offerType"] == "basegame"
        ]

        # check if we have offers in cache
        offer_ids = [entitlement["offerId"] for entitlement in entitlements]
        return await self._get_offers(offer_ids)

    async def get_subscriptions(self) -> List[Subscription]:
        self._check_authenticated()
        return await self._backend_client.get_subscriptions(
            user_id=self._user_id)

    async def prepare_subscription_games_context(
            self, subscription_names: List[str]) -> Any:
        self._check_authenticated()
        subscription_name_to_tier = {
            'EA Play': 'standard',
            'EA Play Pro': 'premium'
        }
        subscriptions = {}
        for sub_name in subscription_names:
            try:
                tier = subscription_name_to_tier[sub_name]
            except KeyError:
                logging.error(
                    "Assertion: 'Galaxy passed unknown subscription name %s. This should not happen!",
                    sub_name)
                raise UnknownError(f'Unknown subscription name {sub_name}!')
            subscriptions[
                sub_name] = await self._backend_client.get_games_in_subscription(
                    tier)
        return subscriptions

    async def get_subscription_games(
            self, subscription_name: str,
            context: Any) -> AsyncGenerator[List[SubscriptionGame], None]:
        if context and subscription_name:
            yield context[subscription_name]

    async def get_local_games(self):
        if self._local_games_update_in_progress:
            logging.debug(
                "LocalGames.update in progress, returning cached values")
            return self._local_games.local_games

        loop = asyncio.get_running_loop()
        try:
            self._local_games_update_in_progress = True
            local_games, _ = await loop.run_in_executor(
                None, partial(LocalGames.update, self._local_games))
            self._local_games_last_update = time.time()
        finally:
            self._local_games_update_in_progress = False
        return local_games

    def handle_local_game_update_notifications(self):
        async def notify_local_games_changed():
            notify_list = []
            try:
                self._local_games_update_in_progress = True
                _, notify_list = await loop.run_in_executor(
                    None, partial(LocalGames.update, self._local_games))
                self._local_games_last_update = time.time()
            finally:
                self._local_games_update_in_progress = False

            for local_game_notify in notify_list:
                self.update_local_game_status(local_game_notify)

        # don't overlap update operations
        if self._local_games_update_in_progress:
            logging.debug(
                "LocalGames.update in progress, skipping cache update")
            return

        if time.time(
        ) - self._local_games_last_update < LOCAL_GAMES_CACHE_VALID_PERIOD:
            logging.debug("Local games cache is fresh enough")
            return

        loop = asyncio.get_running_loop()
        asyncio.create_task(notify_local_games_changed())

    async def prepare_local_size_context(
            self, game_ids) -> Dict[str, pathlib.PurePath]:
        game_id_crc_map = {}
        for filepath, manifest in zip(
                self._local_games._manifests_stats.keys(),
                self._local_games._manifests):
            game_id_crc_map[manifest.game_id] = pathlib.PurePath(
                filepath).parent / 'map.crc'
        return game_id_crc_map

    async def get_local_size(
            self, game_id, context: Dict[str,
                                         pathlib.PurePath]) -> Optional[int]:
        try:
            return parse_map_crc_for_total_size(context[game_id])
        except (KeyError, FileNotFoundError) as e:
            raise UnknownError(
                f"Manifest for game {game_id} is not found: {repr(e)} | context: {context}"
            )

    @staticmethod
    def _get_multiplayer_id(offer) -> Optional[MultiplayerId]:
        for game_platform in offer["platforms"]:
            multiplayer_id = game_platform["multiPlayerId"]
            if multiplayer_id is not None:
                return multiplayer_id
        return None

    async def _get_game_times_for_offer(
            self, offer_id: OfferId, master_title_id: MasterTitleId,
            multiplayer_id: Optional[MultiplayerId],
            lastplayed_time: Optional[Timestamp]) -> GameTime:
        # returns None if a new entry should be retrieved
        def get_cached_game_times(
                _offer_id: OfferId,
                _lastplayed_time: Optional[Timestamp]) -> Optional[GameTime]:
            if _lastplayed_time is None:
                # double-check if 'lastplayed_time' is unknown (maybe it was just to long ago)
                return None

            _cached_game_time: GameTime = self._game_time_cache.get(offer_id)
            if _cached_game_time is None or _cached_game_time.last_played_time is None:
                # played time unknown yet
                return None
            if _lastplayed_time > _cached_game_time.last_played_time:
                # newer played time available
                return None
            return _cached_game_time

        cached_game_time: Optional[GameTime] = get_cached_game_times(
            offer_id, lastplayed_time)
        if cached_game_time is not None:
            return cached_game_time

        response = await self._backend_client.get_game_time(
            self._user_id, master_title_id, multiplayer_id)
        game_time: GameTime = GameTime(offer_id, response[0], response[1])
        self._game_time_cache[offer_id] = game_time
        self._persistent_cache_updated = True
        return game_time

    async def prepare_game_times_context(self, game_ids: List[str]) -> Any:
        self._check_authenticated()

        _, last_played_games = await asyncio.gather(
            self._get_offers(
                game_ids),  # update local cache ignoring return value
            self._backend_client.get_lastplayed_games(self._user_id))

        return last_played_games

    async def get_game_time(self, game_id: OfferId,
                            last_played_games: Any) -> GameTime:
        try:
            offer = self._offer_id_cache.get(game_id)
            if offer is None:
                logging.exception("Internal cache out of sync")
                raise UnknownError()

            master_title_id: MasterTitleId = offer["masterTitleId"]
            multiplayer_id: Optional[MultiplayerId] = self._get_multiplayer_id(
                offer)

            return await self._get_game_times_for_offer(
                game_id, master_title_id, multiplayer_id,
                last_played_games.get(master_title_id))

        except KeyError as e:
            logging.exception("Failed to import game times %s", repr(e))
            raise UnknownBackendResponse()

    async def prepare_game_library_settings_context(
            self, game_ids: List[str]) -> Any:
        self._check_authenticated()
        hidden_games = await self._backend_client.get_hidden_games(
            self._user_id)
        favorite_games = await self._backend_client.get_favorite_games(
            self._user_id)

        library_context = {}
        for game_id in game_ids:
            library_context[game_id] = {
                'hidden': game_id in hidden_games,
                'favorite': game_id in favorite_games
            }
        return library_context

    async def get_game_library_settings(self, game_id: str,
                                        context: Any) -> GameLibrarySettings:
        if not context:
            # Unable to retrieve context
            return GameLibrarySettings(game_id, None, None)
        game_library_settings = context.get(game_id)
        if game_library_settings is None:
            # Able to retrieve context but game is not in its values -> It doesnt have any tags or hidden status set
            return GameLibrarySettings(game_id, [], False)
        return GameLibrarySettings(
            game_id, ['favorite'] if game_library_settings['favorite'] else [],
            game_library_settings['hidden'])

    def game_times_import_complete(self):
        if self._persistent_cache_updated:
            self.push_cache()
            self._persistent_cache_updated = False

    async def get_friends(self):
        self._check_authenticated()

        return [
            FriendInfo(user_id=str(user_id), user_name=str(user_name))
            for user_id, user_name in (
                await self._backend_client.get_friends(self._user_id)).items()
        ]

    async def launch_game(self, game_id):
        if is_uri_handler_installed("origin2"):
            entitlement = self._entitlement_cache.get(game_id)
            if entitlement and 'externalType' in entitlement:
                game_id += '@' + entitlement['externalType'].lower()
            webbrowser.open(
                "origin2://game/launch?offerIds={}&autoDownload=true".format(
                    game_id))
        else:
            webbrowser.open("https://www.origin.com/download")

    async def install_game(self, game_id):
        if is_uri_handler_installed("origin2"):
            webbrowser.open(
                "origin2://game/download?offerId={}".format(game_id))
        else:
            webbrowser.open("https://www.origin.com/download")

    if is_windows():

        async def uninstall_game(self, game_id):
            loop = asyncio.get_running_loop()
            await loop.run_in_executor(
                None, partial(subprocess.run, ["control", "appwiz.cpl"]))

    async def shutdown_platform_client(self) -> None:
        webbrowser.open("origin://quit")

    def _store_cookies(self, cookies):
        credentials = {"cookies": cookies}
        self.store_credentials(credentials)

    def _update_stored_cookies(self, morsels):
        cookies = {}
        for morsel in morsels:
            cookies[morsel.key] = morsel.value
        self._store_cookies(cookies)

    def handshake_complete(self):
        def game_time_decoder(cache: Dict) -> Dict[OfferId, GameTime]:
            def parse_last_played_time(entry):
                # old cache might still contains 0 after plugin upgrade
                lpt = entry.get("last_played_time")
                if lpt == 0:
                    return None
                return lpt

            return {
                offer_id: GameTime(entry["game_id"], entry["time_played"],
                                   parse_last_played_time(entry))
                for offer_id, entry in cache.items() if entry and offer_id
            }

        def safe_decode(_cache: Dict, _key: str, _decoder: Callable):
            if not _cache:
                return {}

            try:
                return _decoder(json.loads(_cache))
            except Exception:
                logging.exception("Failed to decode persistent '%s' cache",
                                  _key)
                return {}

        # parse caches
        for key, decoder in (("offers", lambda x: x),
                             ("game_time", game_time_decoder), ("entitlements",
                                                                lambda x: x)):
            self.persistent_cache[key] = safe_decode(
                self.persistent_cache.get(key), key, decoder)

        self._http_client.load_lats_from_cache(
            self.persistent_cache.get('lats'))
        self._http_client.set_save_lats_callback(self._save_lats)

    def _save_lats(self, lats: int):
        self.persistent_cache['lats'] = str(lats)
        self.push_cache()
class SteamPlugin(Plugin):
    def __init__(self, reader, writer, token):
        super().__init__(Platform.Steam, __version__, reader, writer, token)
        self._steam_id = None
        self._regmon = get_steam_registry_monitor()
        self._local_games_cache: List[LocalGame] = []
        self._http_client = AuthenticatedHttpClient()
        self._client = SteamHttpClient(self._http_client)
        self._achievements_cache = Cache()
        self._achievements_cache_updated = False
        self._achievements_semaphore = asyncio.Semaphore(20)

        self.create_task(self._update_local_games(), "Update local games")

    def _store_cookies(self, cookies):
        credentials = {
            "cookies": morsels_to_dicts(cookies)
        }
        self.store_credentials(credentials)

    @staticmethod
    def _create_two_factor_fake_cookie():
        return Cookie(
            # random SteamID with proper "instance", "type" and "universe" fields
            # (encoded in most significant bits)
            name="steamMachineAuth{}".format(random.randint(1, 2 ** 32 - 1) + 0x01100001 * 2 ** 32),
            # 40-bit random string encoded as hex
            value=hex(random.getrandbits(20 * 8))[2:].upper()
        )

    def shutdown(self):
        asyncio.create_task(self._http_client.close())
        self._regmon.close()

    def handshake_complete(self):
        achievements_cache_ = self.persistent_cache.get("achievements")
        if achievements_cache_ is not None:
            try:
                achievements_cache_ = json.loads(achievements_cache_)
                self._achievements_cache = achievements_cache.from_dict(achievements_cache_)
            except Exception:
                logging.exception("Can not deserialize achievements cache")

    async def _do_auth(self, morsels):
        cookies = [(morsel.key, morsel) for morsel in morsels]

        self._http_client.update_cookies(cookies)
        self._http_client.set_cookies_updated_callback(self._store_cookies)
        self._force_utc()

        try:
            profile_url = await self._client.get_profile()
        except UnknownBackendResponse:
            raise InvalidCredentials()

        try:
            self._steam_id, login = await self._client.get_profile_data(profile_url)
        except AccessDenied:
            raise InvalidCredentials()

        self._http_client.set_auth_lost_callback(self.lost_authentication)

        return Authentication(self._steam_id, login)

    def _force_utc(self):
        cookies = SimpleCookie()
        cookies["timezoneOffset"] = "0,0"
        morsel = cookies["timezoneOffset"]
        morsel["domain"] = "steamcommunity.com"
        # override encoding (steam does not fallow RFC 6265)
        morsel.set("timezoneOffset", "0,0", "0,0")
        self._http_client.update_cookies(cookies)

    async def authenticate(self, stored_credentials=None):
        if not stored_credentials:
            return NextStep(
                "web_session",
                AUTH_PARAMS,
                [self._create_two_factor_fake_cookie()],
                {re.escape(LOGIN_URI): [JS_PERSISTENT_LOGIN]}
            )

        cookies = stored_credentials.get("cookies", [])
        morsels = parse_stored_cookies(cookies)
        return await self._do_auth(morsels)

    async def pass_login_credentials(self, step, credentials, cookies):
        try:
            morsels = dicts_to_morsels(cookies)
        except Exception:
            raise InvalidParams()

        auth_info = await self._do_auth(morsels)
        self._store_cookies(morsels)
        return auth_info

    async def get_owned_games(self):
        if self._steam_id is None:
            raise AuthenticationRequired()

        games = await self._client.get_games(self._steam_id)

        owned_games = []

        try:
            for game in games:
                owned_games.append(
                    Game(
                        str(game["appid"]),
                        game["name"],
                        [],
                        LicenseInfo(LicenseType.SinglePurchase, None)
                    )
                )
        except (KeyError, ValueError):
            logging.exception("Can not parse backend response")
            raise UnknownBackendResponse()

        return owned_games

    async def prepare_game_times_context(self, game_ids: List[str]) -> Any:
        if self._steam_id is None:
            raise AuthenticationRequired()

        return await self._get_game_times_dict()

    async def get_game_time(self, game_id: str, context: Any) -> GameTime:
        game_time = context.get(game_id)
        if game_time is None:
            raise UnknownError("Game {} not owned".format(game_id))
        return game_time

    async def _get_game_times_dict(self) -> Dict[str, GameTime]:
        games = await self._client.get_games(self._steam_id)

        game_times = {}

        try:
            for game in games:
                game_id = str(game["appid"])
                last_played = game.get("last_played")
                if last_played == 86400:
                    # 86400 is used as sentinel value for games no supporting last_played
                    last_played = None
                game_times[game_id] = GameTime(
                    game_id,
                    int(float(game.get("hours_forever", "0").replace(",", "")) * 60),
                    last_played
                )
        except (KeyError, ValueError):
            logging.exception("Can not parse backend response")
            raise UnknownBackendResponse()

        return game_times

    async def prepare_achievements_context(self, game_ids: List[str]) -> Any:
        if self._steam_id is None:
            raise AuthenticationRequired()

        return await self._get_game_times_dict()

    async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]:
        game_time = await self.get_game_time(game_id, context)
        if game_time.time_played == 0:
            return []

        fingerprint = achievements_cache.Fingerprint(game_time.last_played_time, game_time.time_played)
        achievements = self._achievements_cache.get(game_id, fingerprint)

        if achievements is not None:
            # return from cache
            return achievements

        # fetch from backend and update cache
        achievements = await self._get_achievements(game_id)
        self._achievements_cache.update(game_id, achievements, fingerprint)
        self._achievements_cache_updated = True
        return achievements

    def achievements_import_complete(self) -> None:
        if self._achievements_cache_updated:
            self.push_cache()
            self._achievements_cache_updated = False

    async def _get_achievements(self, game_id):
        async with self._achievements_semaphore:
            achievements = await self._client.get_achievements(self._steam_id, game_id)
            return [Achievement(unlock_time, None, name) for unlock_time, name in achievements]

    async def get_friends(self):
        if self._steam_id is None:
            raise AuthenticationRequired()

        return [
            FriendInfo(user_id=user_id, user_name=user_name)
            for user_id, user_name in (await self._client.get_friends(self._steam_id)).items()
        ]

    def tick(self):
        if self._regmon.check_if_updated():
            self.create_task(self._update_local_games(), "Update local games")

    async def _update_local_games(self):
        loop = asyncio.get_running_loop()
        new_list = await loop.run_in_executor(None, local_games_list)
        notify_list = get_state_changes(self._local_games_cache, new_list)
        self._local_games_cache = new_list
        for local_game_notify in notify_list:
            self.update_local_game_status(local_game_notify)

    async def get_local_games(self):
        return self._local_games_cache

    @staticmethod
    async def _steam_command(command, game_id):
        if is_uri_handler_installed("steam"):
            await webbrowser.open("steam://{}/{}".format(command, game_id))
        else:
            await webbrowser.open("https://store.steampowered.com/about/")

    async def launch_game(self, game_id):
        await SteamPlugin._steam_command("launch", game_id)

    async def install_game(self, game_id):
        await SteamPlugin._steam_command("install", game_id)

    async def uninstall_game(self, game_id):
        await SteamPlugin._steam_command("uninstall", game_id)
Exemplo n.º 7
0
class SteamPlugin(Plugin):
    def __init__(self, reader, writer, token):
        super().__init__(Platform.Steam, __version__, reader, writer, token)
        self._steam_id = None
        self._regmon = get_steam_registry_monitor()
        self._local_games_cache = local_games_list()
        self._http_client = AuthenticatedHttpClient()
        self._client = SteamHttpClient(self._http_client)
        self._achievements_cache = Cache()

    def _store_cookies(self, cookies):
        credentials = {"cookies": morsels_to_dicts(cookies)}
        self.store_credentials(credentials)

    @staticmethod
    def _create_two_factor_fake_cookie():
        return Cookie(
            # random SteamID with proper "instance", "type" and "universe" fields
            # (encoded in most significant bits)
            name="steamMachineAuth{}".format(
                random.randint(1, 2**32 - 1) + 0x01100001 * 2**32),
            # 40-bit random string encoded as hex
            value=hex(random.getrandbits(20 * 8))[2:].upper())

    def shutdown(self):
        asyncio.create_task(self._http_client.close())
        self._regmon.close()

    async def _do_auth(self, morsels):
        cookies = [(morsel.key, morsel) for morsel in morsels]

        self._http_client.update_cookies(cookies)
        self._http_client.set_cookies_updated_callback(self._store_cookies)
        self._force_utc()

        try:
            profile_url = await self._client.get_profile()
        except UnknownBackendResponse:
            raise InvalidCredentials()

        try:
            self._steam_id, login = await self._client.get_profile_data(
                profile_url)
        except AccessDenied:
            raise InvalidCredentials()

        self._http_client.set_auth_lost_callback(self.lost_authentication)

        return Authentication(self._steam_id, login)

    def _force_utc(self):
        cookies = SimpleCookie()
        cookies["timezoneOffset"] = "0,0"
        morsel = cookies["timezoneOffset"]
        morsel["domain"] = "steamcommunity.com"
        # override encoding (steam does not fallow RFC 6265)
        morsel.set("timezoneOffset", "0,0", "0,0")
        self._http_client.update_cookies(cookies)

    async def authenticate(self, stored_credentials=None):
        if not stored_credentials:
            return NextStep("web_session", AUTH_PARAMS,
                            [self._create_two_factor_fake_cookie()],
                            {re.escape(LOGIN_URI): [JS_PERSISTENT_LOGIN]})

        cookies = stored_credentials.get("cookies", [])
        morsels = parse_stored_cookies(cookies)
        return await self._do_auth(morsels)

    async def pass_login_credentials(self, step, credentials, cookies):
        try:
            morsels = dicts_to_morsels(cookies)
        except Exception:
            raise InvalidParams()

        auth_info = await self._do_auth(morsels)
        self._store_cookies(morsels)
        return auth_info

    async def get_owned_games(self):
        if self._steam_id is None:
            raise AuthenticationRequired()

        games = await self._client.get_games(self._steam_id)

        owned_games = []

        try:
            for game in games:
                owned_games.append(
                    Game(str(game["appid"]), game["name"], [],
                         LicenseInfo(LicenseType.SinglePurchase, None)))
        except (KeyError, ValueError):
            logging.exception("Can not parse backend response")
            raise UnknownBackendResponse()

        return owned_games

    async def get_game_times(self):
        """"Left for automatic feature detection"""
        if self._steam_id is None:
            raise AuthenticationRequired()
        game_times = await self._get_game_times_dict()
        return list(game_times.values())

    async def start_game_times_import(self, game_ids):
        if self._steam_id is None:
            raise AuthenticationRequired()

        await super().start_game_times_import(game_ids)

    async def import_game_times(self, game_ids):
        remaining_game_ids = set(game_ids)
        try:
            game_times = await self._get_game_times_dict()
            for game_id in game_ids:
                game_time = game_times.get(game_id)
                if game_time is None:
                    self.game_time_import_failure(game_id, UnknownError())
                else:
                    self.game_time_import_success(game_time)
                remaining_game_ids.remove(game_id)
        except Exception as error:
            logging.exception("Fail to import game times")
            for game_id in remaining_game_ids:
                self.game_time_import_failure(game_id, error)

    async def _get_game_times_dict(self) -> Dict[str, GameTime]:
        games = await self._client.get_games(self._steam_id)

        game_times = {}

        try:
            for game in games:
                last_played = game.get("last_played")
                if last_played is None:
                    continue
                game_id = str(game["appid"])
                game_times[game_id] = GameTime(
                    game_id,
                    int(
                        float(game.get("hours_forever", "0").replace(",", ""))
                        * 60), last_played)
        except (KeyError, ValueError):
            logging.exception("Can not parse backend response")
            raise UnknownBackendResponse()

        return game_times

    async def get_unlocked_achievements(self, game_id):
        if self._steam_id is None:
            raise AuthenticationRequired()

        return await self._get_achievements(game_id)

    async def start_achievements_import(self, game_ids):
        if self._steam_id is None:
            raise AuthenticationRequired()

        await super().start_achievements_import(game_ids)

    async def import_games_achievements(self, game_ids):
        remaining_game_ids = set(game_ids)
        try:
            game_times = await self._get_game_times_dict()

            tasks = []
            for game_id in game_ids:
                game_time = game_times.get(game_id)
                if game_time is None or game_time.time_played == 0:
                    # no game time - assume empty achievements
                    self.game_achievements_import_success(game_id, [])
                    continue

                timestamp = game_time.last_played_time
                achievements = self._achievements_cache.get(game_id, timestamp)

                if achievements is not None:
                    # return from cache
                    self.game_achievements_import_success(
                        game_id, achievements)
                    continue

                # fetch from backend and update cache
                tasks.append(
                    asyncio.create_task(
                        self._import_game_achievements(game_id, timestamp)))

            await asyncio.gather(*tasks)
        except Exception as error:
            logging.exception("Failed to retrieve game times")
            for game_id in remaining_game_ids:
                self.game_achievements_import_failure(game_id, error)

    async def _import_game_achievements(self, game_id, timestamp):
        """For fetching single game achievements"""
        try:
            achievements = await self._get_achievements(game_id)
            self.game_achievements_import_success(game_id, achievements)
            self._achievements_cache.update(game_id, achievements, timestamp)
        except Exception as error:
            self.game_achievements_import_failure(game_id, error)

    async def _get_achievements(self, game_id):
        achievements = await self._client.get_achievements(
            self._steam_id, game_id)
        return [
            Achievement(unlock_time, None, name)
            for unlock_time, name in achievements
        ]

    async def get_friends(self):
        if self._steam_id is None:
            raise AuthenticationRequired()

        return [
            FriendInfo(user_id=user_id, user_name=user_name)
            for user_id, user_name in (
                await self._client.get_friends(self._steam_id)).items()
        ]

    def tick(self):
        async def _update_local_games():
            loop = asyncio.get_running_loop()
            new_list = await loop.run_in_executor(None, local_games_list)
            notify_list = get_state_changes(self._local_games_cache, new_list)
            self._local_games_cache = new_list
            for local_game_notify in notify_list:
                self.update_local_game_status(local_game_notify)

        if self._regmon.check_if_updated():
            asyncio.create_task(_update_local_games())

    async def get_local_games(self):
        return self._local_games_cache

    @staticmethod
    async def _open_uri(uri):
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, partial(webbrowser.open, uri))

    @staticmethod
    async def _steam_command(command, game_id):
        if is_uri_handler_installed("steam"):
            await SteamPlugin._open_uri("steam://{}/{}".format(
                command, game_id))
        else:
            await SteamPlugin._open_uri("https://store.steampowered.com/about/"
                                        )

    async def launch_game(self, game_id):
        await SteamPlugin._steam_command("launch", game_id)

    async def install_game(self, game_id):
        await SteamPlugin._steam_command("install", game_id)

    async def uninstall_game(self, game_id):
        await SteamPlugin._steam_command("uninstall", game_id)
class OriginPlugin(Plugin):
    def __init__(self, reader, writer, token):
        super().__init__(Platform.Origin, __version__, reader, writer, token)
        self._user_id = None
        self._persona_id = None

        self._local_games = LocalGames(get_local_content_path())
        self._local_games_last_update = 0
        self._local_games_update_in_progress = False

        def auth_lost():
            self.lost_authentication()

        self._http_client = AuthenticatedHttpClient()
        self._http_client.set_auth_lost_callback(auth_lost)
        self._http_client.set_cookies_updated_callback(
            self._update_stored_cookies)
        self._backend_client = OriginBackendClient(self._http_client)
        self._persistent_cache_updated = False

    @property
    def _game_time_cache(self) -> Dict[OfferId, GameTime]:
        return self.persistent_cache.setdefault("game_time", {})

    @property
    def _offer_id_cache(self) -> Dict[OfferId, Json]:
        return self.persistent_cache.setdefault("offers", {})

    async def shutdown(self):
        await self._http_client.close()

    def tick(self):
        self.handle_local_game_update_notifications()

    def _check_authenticated(self):
        if not self._http_client.is_authenticated():
            logger.exception("Plugin not authenticated")
            raise AuthenticationRequired()

    async def _do_authenticate(self, cookies):
        try:
            await self._http_client.authenticate(cookies)

            self._user_id, self._persona_id, user_name = await self._backend_client.get_identity(
            )
            return Authentication(self._user_id, user_name)

        except (AccessDenied, InvalidCredentials, AuthenticationRequired) as e:
            logger.exception("Failed to authenticate %s", repr(e))
            raise InvalidCredentials()

    async def authenticate(self, stored_credentials=None):
        stored_cookies = stored_credentials.get(
            "cookies") if stored_credentials else None

        if not stored_cookies:
            return NextStep("web_session", AUTH_PARAMS, js=JS)

        return await self._do_authenticate(stored_cookies)

    async def pass_login_credentials(self, step, credentials, cookies):
        new_cookies = {cookie["name"]: cookie["value"] for cookie in cookies}
        auth_info = await self._do_authenticate(new_cookies)
        self._store_cookies(new_cookies)
        return auth_info

    @staticmethod
    def _offer_id_from_game_id(game_id: GameId) -> OfferId:
        return OfferId(game_id.split('@')[0])

    async def get_owned_games(self) -> List[Game]:
        self._check_authenticated()

        owned_offers = await self._get_owned_offers()
        games = []
        for game_id, offer in owned_offers.items():
            game = Game(game_id, offer["i18n"]["displayName"], None,
                        LicenseInfo(LicenseType.SinglePurchase, None))
            games.append(game)

        return games

    @staticmethod
    def _get_achievement_set_override(offer: Json) -> Optional[AchievementSet]:
        potential_achievement_set = None
        for achievement_set in offer["platforms"]:
            potential_achievement_set = achievement_set[
                "achievementSetOverride"]
            if achievement_set["platform"] == "PCWIN":
                return potential_achievement_set
        return potential_achievement_set

    async def prepare_achievements_context(
            self, game_ids: List[GameId]) -> AchievementsImportContext:
        self._check_authenticated()
        owned_offers: Dict[GameId, Json] = await self._get_owned_offers()
        achievement_sets: Dict[OfferId, AchievementSet] = dict()
        for game_id, offer in owned_offers.items():
            achievement_sets[game_id] = self._get_achievement_set_override(
                offer)
        return AchievementsImportContext(owned_games=achievement_sets,
                                         achievements=await
                                         self._backend_client.get_achievements(
                                             self._persona_id))

    async def get_unlocked_achievements(
            self, game_id: GameId,
            context: AchievementsImportContext) -> List[Achievement]:
        try:
            achievements_set = context.owned_games[game_id]
        except KeyError:
            logger.exception(
                "Game '{}' not found amongst owned".format(game_id))
            raise UnknownBackendResponse()

        if not achievements_set:
            return []

        try:
            # for some games(e.g.: ApexLegends) achievement set is not present in "all". have to fetch it explicitly
            achievements = context.achievements.get(achievements_set)
            if achievements is not None:
                return achievements

            return (await self._backend_client.get_achievements(
                self._persona_id, achievements_set))[achievements_set]

        except KeyError:
            logger.exception(
                "Failed to parse achievements for game {}".format(game_id))
            raise UnknownBackendResponse()

    async def _get_offers(self,
                          offer_ids: Iterable[OfferId]) -> Dict[OfferId, Json]:
        """
            Get offers from cache if exists.
            Fetch from backend if not and update cache.
        """
        offers = {}
        missing_offers = []
        for offer_id in offer_ids:
            offer = self._offer_id_cache.get(offer_id, None)
            if offer is not None:
                offers[offer_id] = offer
            else:
                missing_offers.append(offer_id)

        # request for missing offers
        if missing_offers:
            requests = [
                self._backend_client.get_offer(offer_id)
                for offer_id in missing_offers
            ]
            new_offers = await asyncio.gather(*requests,
                                              return_exceptions=True)

            for offer in new_offers:
                if isinstance(offer, Exception):
                    logger.error(repr(offer))
                    continue
                offer_id = offer["offerId"]
                offers[offer_id] = offer
                self._offer_id_cache[offer_id] = offer

            self.push_cache()

        return offers

    async def _get_owned_offers(self) -> Dict[GameId, Json]:
        def get_game_id(entitlement: Json) -> GameId:
            offer_id = entitlement["offerId"]
            external_type = entitlement.get("externalType")
            return GameId(f"{offer_id}@{external_type.lower()}"
                          if external_type else offer_id)

        entitlements = await self._backend_client.get_entitlements(
            self._user_id)
        basegame_entitlements = [
            x for x in entitlements if x["offerType"] == "basegame"
        ]
        basegame_offers = await self._get_offers(
            [x["offerId"] for x in basegame_entitlements])

        return {
            get_game_id(ent): basegame_offers[ent["offerId"]]
            for ent in basegame_entitlements
            if ent["offerId"] in basegame_offers
        }

    async def get_subscriptions(self) -> List[Subscription]:
        self._check_authenticated()
        return await self._backend_client.get_subscriptions(
            user_id=self._user_id)

    async def prepare_subscription_games_context(
            self, subscription_names: List[str]) -> Any:
        self._check_authenticated()
        return {'EA Play': 'standard', 'EA Play Pro': 'premium'}

    async def get_subscription_games(
        self, subscription_name: str,
        context: Dict[str,
                      str]) -> AsyncGenerator[List[SubscriptionGame], None]:
        try:
            tier = context[subscription_name]
        except KeyError:
            raise UnknownError(
                f'Unknown subscription name {subscription_name}!')
        yield await self._backend_client.get_games_in_subscription(tier)

    async def get_local_games(self) -> List[LocalGame]:
        if self._local_games_update_in_progress:
            logger.debug(
                "LocalGames.update in progress, returning cached values")
            return self._local_games.local_games

        loop = asyncio.get_running_loop()
        try:
            self._local_games_update_in_progress = True
            local_games, _ = await loop.run_in_executor(
                None, partial(LocalGames.update, self._local_games))
            self._local_games_last_update = time.time()
        finally:
            self._local_games_update_in_progress = False
        return local_games

    def handle_local_game_update_notifications(self):
        async def notify_local_games_changed():
            notify_list = []
            try:
                self._local_games_update_in_progress = True
                _, notify_list = await loop.run_in_executor(
                    None, partial(LocalGames.update, self._local_games))
                self._local_games_last_update = time.time()
            finally:
                self._local_games_update_in_progress = False

            for local_game_notify in notify_list:
                self.update_local_game_status(local_game_notify)

        # don't overlap update operations
        if self._local_games_update_in_progress:
            logger.debug(
                "LocalGames.update in progress, skipping cache update")
            return

        if time.time(
        ) - self._local_games_last_update < LOCAL_GAMES_CACHE_VALID_PERIOD:
            logger.debug("Local games cache is fresh enough")
            return

        loop = asyncio.get_running_loop()
        asyncio.create_task(notify_local_games_changed())

    async def prepare_local_size_context(
            self, game_ids: List[GameId]) -> Dict[str, pathlib.PurePath]:
        game_id_crc_map: Dict[GameId, str] = {}
        for filepath, manifest in zip(
                self._local_games._manifests_stats.keys(),
                self._local_games._manifests):
            game_id_crc_map[manifest.game_id] = pathlib.PurePath(
                filepath).parent / 'map.crc'
        return game_id_crc_map

    async def get_local_size(
            self, game_id: GameId,
            context: Dict[str, pathlib.PurePath]) -> Optional[int]:
        try:
            return parse_map_crc_for_total_size(context[game_id])
        except (KeyError, FileNotFoundError) as e:
            raise UnknownError(
                f"Manifest for game {game_id} is not found: {repr(e)} | context: {context}"
            )

    @staticmethod
    def _get_multiplayer_id(offer) -> Optional[MultiplayerId]:
        for game_platform in offer["platforms"]:
            multiplayer_id = game_platform["multiPlayerId"]
            if multiplayer_id is not None:
                return multiplayer_id
        return None

    async def _get_game_times_for_master_title(
            self, game_id: GameId, master_title_id: MasterTitleId,
            multiplayer_id: Optional[MultiplayerId],
            lastplayed_time: Optional[Timestamp]) -> GameTime:
        """
        :param game_id - to get from cache
        :param master_title_id - to fetch from backend
        :param multiplayer_id - to fetch from backend
        :param lastplayed_time - to decide on cache freshness
        """
        def get_cached_game_times(
                _game_id: GameId,
                _lastplayed_time: Optional[Timestamp]) -> Optional[GameTime]:
            """"returns None if a new entry should be retrieved"""
            if _lastplayed_time is None:
                # double-check if 'lastplayed_time' is unknown (maybe it was just to long ago)
                return None

            _cached_game_time: GameTime = self._game_time_cache.get(_game_id)
            if _cached_game_time is None or _cached_game_time.last_played_time is None:
                # played time unknown yet
                return None
            if _lastplayed_time > _cached_game_time.last_played_time:
                # newer played time available
                return None
            return _cached_game_time

        cached_game_time: Optional[GameTime] = get_cached_game_times(
            game_id, lastplayed_time)
        if cached_game_time is not None:
            return cached_game_time

        response = await self._backend_client.get_game_time(
            self._user_id, master_title_id, multiplayer_id)
        game_time: GameTime = GameTime(game_id, response[0], response[1])
        self._game_time_cache[game_id] = game_time
        self._persistent_cache_updated = True
        return game_time

    async def prepare_game_times_context(self, game_ids: List[GameId]) -> Any:
        self._check_authenticated()
        offer_ids = [
            self._offer_id_from_game_id(game_id) for game_id in game_ids
        ]

        _, last_played_games = await asyncio.gather(
            self._get_offers(
                offer_ids),  # update local cache ignoring return value
            self._backend_client.get_lastplayed_games(self._user_id))

        return last_played_games

    async def get_game_time(self, game_id: GameId,
                            last_played_games: Any) -> GameTime:
        offer_id = self._offer_id_from_game_id(game_id)
        try:
            offer = self._offer_id_cache.get(offer_id)
            if offer is None:
                logger.exception("Internal cache out of sync")
                raise UnknownError()

            master_title_id: MasterTitleId = offer["masterTitleId"]
            multiplayer_id: Optional[MultiplayerId] = self._get_multiplayer_id(
                offer)

            return await self._get_game_times_for_master_title(
                game_id, master_title_id, multiplayer_id,
                last_played_games.get(master_title_id))

        except KeyError as e:
            logger.exception("Failed to import game times %s", repr(e))
            raise UnknownBackendResponse()

    def game_times_import_complete(self):
        if self._persistent_cache_updated:
            self.push_cache()
            self._persistent_cache_updated = False

    async def prepare_game_library_settings_context(
            self, game_ids: List[GameId]) -> GameLibrarySettingsContext:
        self._check_authenticated()
        favorite_games, hidden_games = await asyncio.gather(
            self._backend_client.get_favorite_games(self._user_id),
            self._backend_client.get_hidden_games(self._user_id))
        return GameLibrarySettingsContext(favorite=favorite_games,
                                          hidden=hidden_games)

    async def get_game_library_settings(
            self, game_id: GameId,
            context: GameLibrarySettingsContext) -> GameLibrarySettings:
        normalized_id = game_id.strip("@subscription")
        return GameLibrarySettings(
            game_id,
            tags=['favorite'] if normalized_id in context.favorite else [],
            hidden=normalized_id in context.hidden)

    async def get_friends(self):
        self._check_authenticated()

        return [
            FriendInfo(user_id=str(user_id), user_name=str(user_name))
            for user_id, user_name in (
                await self._backend_client.get_friends(self._user_id)).items()
        ]

    @staticmethod
    def _open_uri(uri):
        logger.info("Opening {}".format(uri))
        webbrowser.open(uri)

    async def launch_game(self, game_id: GameId):
        if is_uri_handler_installed("origin2"):
            uri = "origin2://game/launch?offerIds={}&autoDownload=1".format(
                game_id)
        else:
            uri = "https://www.origin.com/download"

        self._open_uri(uri)

    async def install_game(self, game_id: GameId):
        def is_subscription_game(game_id: GameId) -> bool:
            return game_id.endswith('subscription')

        def is_offer_missing_from_user_library(offer_id: OfferId):
            return offer_id not in self._offer_id_cache

        async def get_subscription_game_store_uri(offer_id):
            try:
                offer = await self._backend_client.get_offer(offer_id)
                return "https://www.origin.com/store/{}".format(
                    offer["gdpPath"])
            except (KeyError, UnknownError, BackendError,
                    UnknownBackendResponse):
                return "https://www.origin.com/store/ea-play/play-list"

        offer_id = self._offer_id_from_game_id(game_id)
        if is_subscription_game(
                game_id) and is_offer_missing_from_user_library(offer_id):
            uri = await get_subscription_game_store_uri(offer_id)
        elif is_uri_handler_installed("origin2"):
            uri = f"origin2://game/download?offerId={game_id}"
        else:
            uri = "https://www.origin.com/download"

        self._open_uri(uri)

    if is_windows():

        async def uninstall_game(self, game_id: GameId):
            loop = asyncio.get_running_loop()
            await loop.run_in_executor(
                None, partial(subprocess.run, ["control", "appwiz.cpl"]))

    async def shutdown_platform_client(self) -> None:
        self._open_uri("origin://quit")

    def _store_cookies(self, cookies):
        credentials = {"cookies": cookies}
        self.store_credentials(credentials)

    def _update_stored_cookies(self, morsels):
        cookies = {}
        for morsel in morsels:
            cookies[morsel.key] = morsel.value
        self._store_cookies(cookies)

    def handshake_complete(self):
        def game_time_decoder(cache: Dict) -> Dict[OfferId, GameTime]:

            # after offerId -> gameId migration
            outdated_keys = [key.split('@')[0] for key in cache if "@" in key]
            for i in outdated_keys:
                cache.pop(i, None)

            return {
                game_id: GameTime(entry["game_id"], entry["time_played"],
                                  entry.get("last_played_time"))
                for game_id, entry in cache.items() if entry and game_id
            }

        def safe_decode(_cache: Dict, _key: str, _decoder: Callable):
            if not _cache:
                return {}
            if _decoder is None:
                _decoder = lambda x: x

            try:
                return _decoder(json.loads(_cache))
            except Exception:
                logger.exception("Failed to decode persistent '%s' cache",
                                 _key)
                return {}

        # parse caches
        cache_decoders = {
            "offers": None,
            "game_time": game_time_decoder,
        }
        for key, decoder in cache_decoders.items():
            self.persistent_cache[key] = safe_decode(
                self.persistent_cache.get(key), key, decoder)

        self._http_client.load_lats_from_cache(
            self.persistent_cache.get('lats'))
        self._http_client.set_save_lats_callback(self._save_lats)

    def _save_lats(self, lats: int):
        self.persistent_cache['lats'] = str(lats)
        self.push_cache()