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._steam_client = WebSocketClient(self._client, self._ssl_context, self._servers_cache, self._friends_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._game_tags_cache = {} self._update_task = None def user_presence_update_handler(user_id: str, user_info: UserInfo): self.update_user_presence(user_id, from_user_info(user_info)) self._friends_cache.updated_handler = user_presence_update_handler
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 __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 __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
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
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 __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: 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")
async def http_client(): client = AuthenticatedHttpClient() yield client await client.close()
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._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()
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()
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()
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)
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()